mirror of
https://github.com/BlowaterNostr/blowater.git
synced 2024-10-18 07:33:22 +00:00
local pin (#373)
This commit is contained in:
parent
0d43a4e597
commit
65f98f4236
@ -7,6 +7,7 @@ import {
|
||||
NostrKind,
|
||||
UnsignedNostrEvent,
|
||||
} from "../../libs/nostr.ts/nostr.ts";
|
||||
import { LocalPrivateKeyController } from "./signIn.tsx";
|
||||
|
||||
type NIP07 = {
|
||||
getPublicKey(): Promise<string>;
|
||||
@ -115,16 +116,12 @@ export class Nip7ExtensionContext implements NostrAccountContext {
|
||||
};
|
||||
}
|
||||
|
||||
export function GetLocalStorageAccountContext() {
|
||||
const loginPrivateKey = localStorage.getItem("MPK");
|
||||
if (loginPrivateKey) {
|
||||
const priKey = PrivateKey.FromHex(loginPrivateKey);
|
||||
if (!(priKey instanceof Error)) {
|
||||
return InMemoryAccountContext.New(priKey);
|
||||
}
|
||||
console.error("the stored MPK is not a valid private, removing it");
|
||||
localStorage.removeItem("MPK");
|
||||
export async function GetLocalStorageAccountContext(pin: string) {
|
||||
const priKey = await LocalPrivateKeyController.getKey(pin);
|
||||
if (priKey instanceof Error) {
|
||||
return priKey;
|
||||
} else if (priKey == undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
return InMemoryAccountContext.New(priKey);
|
||||
}
|
||||
|
@ -24,7 +24,14 @@ import { EventSyncer } from "./event_syncer.ts";
|
||||
import { RelayConfig } from "./relay-config.ts";
|
||||
import { ProfileGetter } from "./search.tsx";
|
||||
import { Setting } from "./setting.tsx";
|
||||
import { getCurrentSignInCtx, setSignInState, SignIn } from "./signIn.tsx";
|
||||
import {
|
||||
forgot_pin,
|
||||
getCurrentSignInCtx,
|
||||
getPinFromUser,
|
||||
getSignInState,
|
||||
setSignInState,
|
||||
SignIn,
|
||||
} from "./signIn.tsx";
|
||||
import { SecondaryBackgroundColor } from "./style/colors.ts";
|
||||
import { LamportTime } from "../time.ts";
|
||||
import { InstallPrompt, NavBar } from "./nav.tsx";
|
||||
@ -56,23 +63,37 @@ export async function Start(database: DexieDatabase) {
|
||||
}
|
||||
})();
|
||||
|
||||
const ctx = await getCurrentSignInCtx();
|
||||
if (ctx instanceof Error) {
|
||||
console.error(ctx);
|
||||
} else if (ctx) {
|
||||
const otherConfig = await OtherConfig.FromLocalStorage(ctx, newNostrEventChannel, lamport);
|
||||
const app = await App.Start({
|
||||
database: dbView,
|
||||
model,
|
||||
ctx,
|
||||
eventBus,
|
||||
pool,
|
||||
popOverInputChan,
|
||||
otherConfig,
|
||||
lamport,
|
||||
installPrompt,
|
||||
});
|
||||
model.app = app;
|
||||
{
|
||||
let err: Error | undefined;
|
||||
for (;;) {
|
||||
if (getSignInState() === "none") {
|
||||
break;
|
||||
}
|
||||
const pin = await getPinFromUser(err);
|
||||
if (pin == forgot_pin) {
|
||||
break;
|
||||
}
|
||||
const ctx = await getCurrentSignInCtx(pin);
|
||||
if (ctx instanceof Error) {
|
||||
err = ctx;
|
||||
continue;
|
||||
} else if (ctx) {
|
||||
const otherConfig = await OtherConfig.FromLocalStorage(ctx, newNostrEventChannel, lamport);
|
||||
const app = await App.Start({
|
||||
database: dbView,
|
||||
model,
|
||||
ctx,
|
||||
eventBus,
|
||||
pool,
|
||||
popOverInputChan,
|
||||
otherConfig,
|
||||
lamport,
|
||||
installPrompt,
|
||||
});
|
||||
model.app = app;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* first render */ render(
|
||||
|
@ -1,7 +1,6 @@
|
||||
/** @jsx h */
|
||||
import { h } from "https://esm.sh/preact@10.17.1";
|
||||
import { render } from "https://esm.sh/preact@10.17.1";
|
||||
import { PrivateKey } from "../../libs/nostr.ts/key.ts";
|
||||
import { testEventBus } from "./_setup.test.ts";
|
||||
import { SignIn } from "./signIn.tsx";
|
||||
|
||||
|
@ -1,5 +1,14 @@
|
||||
/** @jsx h */
|
||||
import { Component, h } from "https://esm.sh/preact@10.17.1";
|
||||
import {
|
||||
Attributes,
|
||||
Component,
|
||||
ComponentChild,
|
||||
ComponentChildren,
|
||||
createRef,
|
||||
h,
|
||||
Ref,
|
||||
render,
|
||||
} from "https://esm.sh/preact@10.17.1";
|
||||
import { GetLocalStorageAccountContext, Nip7ExtensionContext } from "./account-context.ts";
|
||||
import { ButtonClass, CenterClass, LinearGradientsClass, NoOutlineClass } from "./components/tw.ts";
|
||||
import KeyView from "./key-view.tsx";
|
||||
@ -40,7 +49,7 @@ export function setSignInState(state: SignInState) {
|
||||
////////////////////////
|
||||
// Check Login Status //
|
||||
////////////////////////
|
||||
export async function getCurrentSignInCtx() {
|
||||
export async function getCurrentSignInCtx(pin: string) {
|
||||
if (getSignInState() === "nip07") {
|
||||
const nip07Ctx = await Nip7ExtensionContext.New();
|
||||
if (nip07Ctx instanceof Error) {
|
||||
@ -52,9 +61,9 @@ export async function getCurrentSignInCtx() {
|
||||
return nip07Ctx;
|
||||
}
|
||||
if (getSignInState() === "local") {
|
||||
const ctx = GetLocalStorageAccountContext();
|
||||
const ctx = await GetLocalStorageAccountContext(pin);
|
||||
if (ctx instanceof Error) {
|
||||
throw ctx;
|
||||
return ctx;
|
||||
}
|
||||
if (ctx === undefined) {
|
||||
console.log("GetLocalStorageAccountContext is undefined");
|
||||
@ -70,22 +79,24 @@ type Props = {
|
||||
};
|
||||
|
||||
type State = {
|
||||
state: "newAccount" | "enterPrivateKey";
|
||||
step: "newAccount" | "enterPrivateKey" | "enter local pin" | "confirm local pin";
|
||||
localPin: string;
|
||||
privateKey: PrivateKey;
|
||||
privateKeyError: string;
|
||||
nip07Error: string;
|
||||
};
|
||||
export class SignIn extends Component<Props, State> {
|
||||
localPinInput = createRef<HTMLInputElement>();
|
||||
|
||||
styles = {
|
||||
container:
|
||||
`h-screen w-screen bg-[${PrimaryBackgroundColor}] flex items-center justify-center p-4 overflow-y-auto`,
|
||||
container: `h-screen w-screen bg-[${PrimaryBackgroundColor}] ` +
|
||||
`flex flex-col items-center justify-center p-4 overflow-y-auto`,
|
||||
form: `w-[30rem] flex flex-col h-full py-8`,
|
||||
logo: `w-32 h-32 mx-auto`,
|
||||
title: `text-[${PrimaryTextColor}] text-center text-4xl`,
|
||||
subTitle: `text-[${HintTextColor}] text-center`,
|
||||
input: `w-full px-4 py-2 focus-visible:outline-none rounded-lg mt-8`,
|
||||
hint: `text-[${HintTextColor}] text-sm mt-2`,
|
||||
block: `flex-1 desktop:hidden`,
|
||||
signInButton:
|
||||
`w-full mt-4 ${ButtonClass} ${LinearGradientsClass} hover:bg-gradient-to-l mobile:rounded-full font-bold`,
|
||||
cancelButton:
|
||||
@ -104,13 +115,8 @@ export class SignIn extends Component<Props, State> {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = InMemoryAccountContext.New(this.state.privateKey);
|
||||
localStorage.setItem("MPK", this.state.privateKey.hex);
|
||||
setSignInState("local");
|
||||
|
||||
this.props.emit({
|
||||
type: "SignInEvent",
|
||||
ctx: ctx,
|
||||
this.setState({
|
||||
step: "enter local pin",
|
||||
});
|
||||
};
|
||||
|
||||
@ -140,13 +146,13 @@ export class SignIn extends Component<Props, State> {
|
||||
this.setState({
|
||||
privateKey: PrivateKey.Generate(),
|
||||
privateKeyError: "",
|
||||
state: "newAccount",
|
||||
step: "newAccount",
|
||||
});
|
||||
};
|
||||
|
||||
cancelNew = () => {
|
||||
this.setState({
|
||||
state: "enterPrivateKey",
|
||||
step: "enterPrivateKey",
|
||||
privateKey: undefined,
|
||||
});
|
||||
};
|
||||
@ -170,7 +176,7 @@ export class SignIn extends Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.state == "newAccount") {
|
||||
if (this.state.step == "newAccount") {
|
||||
return (
|
||||
<div class={this.styles.container}>
|
||||
<div class={this.styles.form}>
|
||||
@ -181,7 +187,6 @@ export class SignIn extends Component<Props, State> {
|
||||
<p class={this.styles.hint}>
|
||||
Please back up your <strong>Private Key</strong>
|
||||
</p>
|
||||
<div class={this.styles.block}></div>
|
||||
<button
|
||||
onClick={this.cancelNew}
|
||||
class={this.styles.cancelButton}
|
||||
@ -197,49 +202,257 @@ export class SignIn extends Component<Props, State> {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={this.styles.container}>
|
||||
<div class={this.styles.form}>
|
||||
<img class={this.styles.logo} src="logo.webp" alt="Logo" />
|
||||
<h1 class={this.styles.title}>Blowater</h1>
|
||||
<p class={this.styles.subTitle}>A delightful Nostr client that focuses on DM</p>
|
||||
<input
|
||||
onInput={(e) => this.inputPrivateKey(e.currentTarget.value)}
|
||||
placeholder="Input your private key here"
|
||||
type="password"
|
||||
class={this.styles.input}
|
||||
autofocus
|
||||
/>
|
||||
<p class={this.styles.hint}>
|
||||
<span class={this.styles.isError(this.state.privateKeyError)}>
|
||||
Private Key has to be <strong>64</strong> letters hex-decimal or{" "}
|
||||
<strong>63</strong> letters nsec string.
|
||||
</span>{" "}
|
||||
Don't have an account yet?{" "}
|
||||
<button onClick={this.newAccount} class={this.styles.newButton}>create one!</button>
|
||||
</p>
|
||||
<div class={this.styles.block}></div>
|
||||
} else if (this.state.step == "enter local pin") {
|
||||
return (
|
||||
<div class={this.styles.container}>
|
||||
<div class="block text-white">
|
||||
Please enter a pin that is used to encrypt your private key on-device
|
||||
</div>
|
||||
<input ref={this.localPinInput} type="password"></input>
|
||||
<button
|
||||
onClick={this.signInWithPrivateKey}
|
||||
class={this.styles.signInButton}
|
||||
class="text-white border mt-1 px-2 hover:bg-zinc-200"
|
||||
onClick={() => {
|
||||
const input = this.localPinInput.current;
|
||||
if (input) {
|
||||
const pin = input.value;
|
||||
this.setState({
|
||||
localPin: pin,
|
||||
step: "confirm local pin",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => await this.signInWithExtension()}
|
||||
class={this.styles.signInButton}
|
||||
>
|
||||
Sign in with Nostr Extension
|
||||
</button>
|
||||
<p class={this.styles.hint}>
|
||||
<span class={this.styles.isError(this.state.nip07Error)}>
|
||||
{this.state.nip07Error}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.step == "confirm local pin") {
|
||||
const input = this.localPinInput.current;
|
||||
if (input) {
|
||||
input.value = "";
|
||||
}
|
||||
return (
|
||||
<div class={this.styles.container}>
|
||||
<div class="block text-white">Please enter the pin you just typed</div>
|
||||
<input ref={this.localPinInput} type="password"></input>
|
||||
<button
|
||||
class="text-white border mt-1 px-2 hover:bg-zinc-200"
|
||||
onClick={() => {
|
||||
const input = this.localPinInput.current;
|
||||
if (input) {
|
||||
const pin = input.value;
|
||||
console.log(this.state.localPin, pin, this.state.localPin == pin);
|
||||
if (this.state.localPin == pin) {
|
||||
const ctx = InMemoryAccountContext.New(this.state.privateKey);
|
||||
|
||||
LocalPrivateKeyController.setKey(pin, this.state.privateKey);
|
||||
|
||||
setSignInState("local");
|
||||
|
||||
this.props.emit({
|
||||
type: "SignInEvent",
|
||||
ctx: ctx,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div class={this.styles.container}>
|
||||
<div class={this.styles.form}>
|
||||
<img class={this.styles.logo} src="logo.webp" alt="Logo" />
|
||||
<h1 class={this.styles.title}>Blowater</h1>
|
||||
<p class={this.styles.subTitle}>A delightful Nostr client that focuses on DM</p>
|
||||
<input
|
||||
onInput={(e) => this.inputPrivateKey(e.currentTarget.value)}
|
||||
placeholder="Input your private key here"
|
||||
type="password"
|
||||
class={this.styles.input}
|
||||
autofocus
|
||||
/>
|
||||
<p class={this.styles.hint}>
|
||||
<span class={this.styles.isError(this.state.privateKeyError)}>
|
||||
Private Key has to be <strong>64</strong> letters hex-decimal or{" "}
|
||||
<strong>63</strong> letters nsec string.
|
||||
</span>{" "}
|
||||
Don't have an account yet?{" "}
|
||||
<button onClick={this.newAccount} class={this.styles.newButton}>
|
||||
create one!
|
||||
</button>
|
||||
</p>
|
||||
<div class={"flex-1"}></div>
|
||||
<button
|
||||
onClick={this.signInWithPrivateKey}
|
||||
class={this.styles.signInButton}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => await this.signInWithExtension()}
|
||||
class={this.styles.signInButton}
|
||||
>
|
||||
Sign in with Nostr Extension
|
||||
</button>
|
||||
<p class={this.styles.hint}>
|
||||
<span class={this.styles.isError(this.state.nip07Error)}>
|
||||
{this.state.nip07Error}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const forgot_pin = Symbol("forgot_pin");
|
||||
class AskForLocalPin extends Component<{
|
||||
resolve: (pin: string | typeof forgot_pin) => void;
|
||||
err: Error | undefined;
|
||||
}, {}> {
|
||||
input = createRef<HTMLInputElement>();
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
class={`h-screen w-screen bg-[${PrimaryBackgroundColor}] ` +
|
||||
`flex flex-col items-center justify-center p-4 overflow-y-auto`}
|
||||
>
|
||||
<div class="block text-white">Please enter your local pin</div>
|
||||
<input ref={this.input} type="password"></input>
|
||||
<button
|
||||
class="text-white border mt-1 px-2 hover:bg-zinc-200"
|
||||
onClick={() => {
|
||||
const input = this.input.current;
|
||||
if (input) {
|
||||
this.props.resolve(input.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
<button
|
||||
class="text-white border mt-1 px-2 hover:bg-zinc-200"
|
||||
onClick={() => {
|
||||
const input = this.input.current;
|
||||
if (input) {
|
||||
this.props.resolve(forgot_pin);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Forgot the pin
|
||||
</button>
|
||||
{this.props.err ? <div class="block text-white">{this.props.err.message}</div> : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPinFromUser(err: Error | undefined) {
|
||||
return new Promise<string | typeof forgot_pin>((resolve) => {
|
||||
console.log(err);
|
||||
render(<AskForLocalPin resolve={resolve} err={err}></AskForLocalPin>, document.body);
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalPrivateKeyController {
|
||||
static cleanOldVersionDate() {
|
||||
localStorage.removeItem("MPK");
|
||||
}
|
||||
|
||||
static async setKey(pin: string, pri: PrivateKey) {
|
||||
// hash the pin
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(pin);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
|
||||
// encrypt the private key
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
hash,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt"],
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
key,
|
||||
new TextEncoder().encode(pri.hex),
|
||||
);
|
||||
|
||||
// store the key
|
||||
localStorage.setItem(
|
||||
`private key`,
|
||||
JSON.stringify({
|
||||
encrypted: toBase64(new Uint8Array(encrypted)),
|
||||
iv: toBase64(new Uint8Array(iv)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static async getKey(pin: string): Promise<PrivateKey | Error | undefined> {
|
||||
// Retrieve the encrypted data from localStorage
|
||||
const stored = localStorage.getItem(`private key`);
|
||||
if (!stored) return undefined;
|
||||
|
||||
const { encrypted, iv } = JSON.parse(stored);
|
||||
const encryptedData = decodeBase64(encrypted);
|
||||
const ivData = decodeBase64(iv);
|
||||
|
||||
// Hash the pin
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(pin);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
|
||||
// Decrypt the private key
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
hash,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["decrypt"],
|
||||
);
|
||||
|
||||
try {
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: ivData },
|
||||
key,
|
||||
encryptedData,
|
||||
);
|
||||
const private_hex = new TextDecoder().decode(decrypted);
|
||||
return PrivateKey.FromHex(private_hex);
|
||||
} catch (e) {
|
||||
return new Error("wrong pin");
|
||||
}
|
||||
}
|
||||
}
|
||||
LocalPrivateKeyController.cleanOldVersionDate();
|
||||
|
||||
function toBase64(uInt8Array: Uint8Array) {
|
||||
let strChunks = new Array(uInt8Array.length);
|
||||
let i = 0;
|
||||
for (let byte of uInt8Array) {
|
||||
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
|
||||
i++;
|
||||
}
|
||||
return btoa(strChunks.join(""));
|
||||
}
|
||||
|
||||
function decodeBase64(base64String: string) {
|
||||
const binaryString = atob(base64String);
|
||||
const length = binaryString.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user