Skip to content
Merged
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
149 changes: 79 additions & 70 deletions frontend/src/pages/SecurityKeyManagement.tsx
Original file line number Diff line number Diff line change
@@ -1,93 +1,102 @@
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { KeyRound, ShieldCheck, ShieldAlert, Lock } from 'lucide-react';
import Button from '@/components/bits/Button';
import { useAuth } from '@/context/AuthContext';
import { Lock } from 'lucide-react';
import Toggle from '@/components/bits/Toggle';
import ConfirmDialog from '@/components/bits/ConfirmDialog';
import { toErrorMessage } from '@/lib/errors';
import { GetMasterKeyStorage, SetMasterKeyStorage } from '../../wailsjs/go/auth/Service';

type MasterKeyStorage = 'database' | 'keyring';

export default function SecurityKeyManagement() {
const { session } = useAuth();
const [storage, setStorage] = useState<MasterKeyStorage | null>(null);
const [pendingStorage, setPendingStorage] = useState<MasterKeyStorage | null>(null);

const loadStorage = async () => {
try {
const current = await GetMasterKeyStorage();
setStorage(current === 'keyring' ? 'keyring' : 'database');
} catch (error) {
console.error('Failed to load master key storage:', error);
}
};

useEffect(() => {
loadStorage();
}, []);

const handleDownloadRecoveryKey = () => {
toast('Your recovery key is only shown once, right after registration or password reset.');
const handleToggle = (checked: boolean) => {
setPendingStorage(checked ? 'keyring' : 'database');
};

const keys = [
{
icon: <Lock className="h-5 w-5" />,
title: 'Master Key',
description:
'Derived from your password and used to unlock everything. Never stored on disk.',
status: 'Derived in-memory',
ok: true,
},
{
icon: <KeyRound className="h-5 w-5" />,
title: 'Recovery Key',
description:
'Backs up your database credentials. Shown once at registration and after a password reset.',
status: 'Shown once',
ok: false,
},
{
icon: <ShieldCheck className="h-5 w-5" />,
title: 'Key Encryption Key (KEK)',
description: 'Encrypts your stored database credentials with password and recovery keys.',
status: 'Active',
ok: true,
},
];
const handleConfirm = async () => {
const target = pendingStorage;
setPendingStorage(null);
if (!target || target === storage) return;

try {
const result = await SetMasterKeyStorage(target);
setStorage(result === 'keyring' ? 'keyring' : 'database');
toast.success(
result === 'keyring'
? 'Master key moved to the OS keyring.'
: 'Master key moved back to the database.'
);
} catch (error) {
console.error('Failed to change master key storage:', error);
toast.error(toErrorMessage(error, 'Failed to move the master key.'));
}
};

return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-text">Key Management</h2>
<p className="mt-2 text-sm text-text-muted">
Review how {session?.Username || 'your account'}&apos;s encryption keys are protected.
Choose where your encrypted master key is stored.
</p>
</div>

<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{keys.map((k) => (
<div
key={k.title}
className="rounded-2xl border-2 border-border bg-background backdrop-blur-sm p-6 shadow-lg dark:border-border-strong"
>
<div className="flex items-center gap-3">
<div className="rounded-xl bg-primary/10 p-2.5 dark:bg-primary/20">
<span className="text-primary">{k.icon}</span>
</div>
<h3 className="text-sm font-bold text-text">{k.title}</h3>
</div>
<p className="mt-3 text-sm text-text-muted leading-relaxed">{k.description}</p>
<p
className={`mt-4 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-semibold ${
k.ok
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{k.ok ? (
<ShieldCheck className="h-3.5 w-3.5" />
) : (
<ShieldAlert className="h-3.5 w-3.5" />
)}
{k.status}
<div className="rounded-2xl border-2 border-border bg-background backdrop-blur-sm p-6 shadow-lg dark:border-border-strong">
<div className="flex items-center gap-4">
<div className="rounded-xl bg-primary/10 p-2.5 dark:bg-primary/20">
<Lock className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-xs font-bold uppercase tracking-wider text-text-faint dark:text-text-subtle">
Master Key Storage
</p>
<p className="text-lg font-bold text-text">
{storage === null ? 'Checking...' : storage === 'keyring' ? 'OS keyring' : 'Database'}
</p>
</div>
))}
</div>
</div>

<div className="rounded-2xl border-2 border-border bg-background backdrop-blur-sm p-6 shadow-lg dark:border-border-strong">
<h3 className="text-base font-bold text-text">Recovery</h3>
<p className="mt-1 text-sm text-text-muted leading-relaxed">
Your recovery key is essential for resetting your password. It is only ever shown once, so
if you lost it, resetting your password is the only way to obtain a new one.
</p>
<div className="mt-4">
<Button type="button" variant="ghost" onClick={handleDownloadRecoveryKey}>
Download Recovery Key
</Button>
<div className="mt-6">
<Toggle
id="master-key-keyring"
label="Store master key in OS keyring"
description="Keeps both the password and recovery copies (salt, nonce and ciphertext) in the system keychain. The database columns then hold random data."
checked={storage === 'keyring'}
disabled={storage === null}
onChange={(e) => handleToggle(e.target.checked)}
/>
</div>
</div>

<ConfirmDialog
isOpen={pendingStorage !== null}
title="Move encrypted master key?"
message={
pendingStorage === 'keyring'
? 'The password and recovery copies of your master key (salts, nonces and ciphertext) will be moved to the OS keyring, and your database will only keep random data. This makes the key material recoverable only through the system credential store.'
: 'The password and recovery copies of your master key (salts, nonces and ciphertext) will be moved from the OS keyring back into your database, and the keyring entry will be deleted.'
}
confirmLabel="Move Key"
destructive={false}
onConfirm={handleConfirm}
onCancel={() => setPendingStorage(null)}
/>
</div>
);
}
4 changes: 4 additions & 0 deletions frontend/wailsjs/go/auth/Service.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export function CurrentClient():Promise<db.Client>;

export function DatabaseConfig():Promise<db.Config>;

export function GetMasterKeyStorage():Promise<string>;

export function GetSession():Promise<auth.Session>;

export function Login(arg1:auth.LoginInput):Promise<boolean>;
Expand All @@ -18,3 +20,5 @@ export function Register(arg1:auth.RegisterInput):Promise<auth.RegisterResult>;
export function RequireSession():Promise<auth.Session>;

export function ResetPassword(arg1:auth.ResetPasswordInput):Promise<auth.RegisterResult>;

export function SetMasterKeyStorage(arg1:string):Promise<string>;
8 changes: 8 additions & 0 deletions frontend/wailsjs/go/auth/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export function DatabaseConfig() {
return window['go']['auth']['Service']['DatabaseConfig']();
}

export function GetMasterKeyStorage() {
return window['go']['auth']['Service']['GetMasterKeyStorage']();
}

export function GetSession() {
return window['go']['auth']['Service']['GetSession']();
}
Expand All @@ -33,3 +37,7 @@ export function RequireSession() {
export function ResetPassword(arg1) {
return window['go']['auth']['Service']['ResetPassword'](arg1);
}

export function SetMasterKeyStorage(arg1) {
return window['go']['auth']['Service']['SetMasterKeyStorage'](arg1);
}
19 changes: 19 additions & 0 deletions internal/features/auth/model.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package auth

import (
"ayo/internal/features/masterkey"
)

// User is the persisted representation of an account, mirroring one row of the
// `users` table. It stores only hashes and encrypted material - never plaintext
// credentials or keys. The plaintext recovery key is returned to the caller via
Expand Down Expand Up @@ -32,3 +36,18 @@ type User struct {
RecoveryNonce []byte
RecoveryMasterKey []byte
}

// MasterKeyMaterial returns the user's encrypted master-key material as read
// from the users table. When the account stores its material in the OS keyring
// instead, these columns carry junk and callers must load the material from the
// keyring via the masterkey repository.
func (u *User) MasterKeyMaterial() *masterkey.Material {
return &masterkey.Material{
PasswordSalt: u.PasswordSalt,
PasswordNonce: u.PasswordNonce,
PasswordMasterKey: u.PasswordMasterKey,
RecoverySalt: u.RecoverySalt,
RecoveryNonce: u.RecoveryNonce,
RecoveryMasterKey: u.RecoveryMasterKey,
}
}
55 changes: 37 additions & 18 deletions internal/features/auth/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sync"

dbclient "ayo/internal/clients/db"
"ayo/internal/features/masterkey"
"ayo/internal/shared/errors"
)

Expand All @@ -29,16 +30,13 @@ type Repository interface {
recoveryMasterKey []byte,
) (*User, error)
GetUserByUsername(ctx context.Context, username string) (*User, error)
UpdateUserPassword(
UpdateUserHashes(
ctx context.Context,
id int64,
passwordHash string,
recoveryKey string,
passwordMasterKey []byte,
passwordNonce []byte,
recoveryMasterKey []byte,
recoveryNonce []byte,
) error
UpdateMasterKeyMaterial(ctx context.Context, id int64, material *masterkey.Material) error
}

type repository struct {
Expand Down Expand Up @@ -197,21 +195,41 @@ func (r *repository) GetUserByUsername(ctx context.Context, username string) (*U
return &user, nil
}

// UpdateUserPassword replaces the password/recovery-key hashes and re-wraps the
// master key with the newly derived KEKs. Used by the reset-password flow.
func (r *repository) UpdateUserPassword(
// UpdateUserHashes replaces the password and recovery-key bcrypt hashes for the
// given user. Used by the reset-password flow; the encrypted master-key material
// is updated separately (see UpdateMasterKeyMaterial) so that keyring-stored
// accounts keep junk in the database.
func (r *repository) UpdateUserHashes(
ctx context.Context,
id int64,
passwordHash string,
recoveryKey string,
passwordMasterKey []byte,
passwordNonce []byte,
recoveryMasterKey []byte,
recoveryNonce []byte,
) error {
query := `UPDATE users SET password_hash = ?, recovery_key = ?, ` +
`password_master_key = ?, password_nonce = ?, recovery_master_key = ?, ` +
`recovery_nonce = ? WHERE id = ?`
query := `UPDATE users SET password_hash = ?, recovery_key = ? WHERE id = ?`

client, err := r.resolve()
if err != nil {
return err
}
_, err = client.ExecContext(
ctx,
client.Rebind(query),
passwordHash, recoveryKey, id,
)
if err != nil {
return fmt.Errorf("failed to update user hashes: %w", err)
}
return nil
}

// UpdateMasterKeyMaterial replaces the six encrypted master-key columns for the
// given user. It is used to migrate the material between the users table and
// the OS keyring: when the material moves to the keyring, this writes
// indistinguishable random junk; when it moves back, it writes the real values.
func (r *repository) UpdateMasterKeyMaterial(ctx context.Context, id int64, material *masterkey.Material) error {
query := `UPDATE users SET password_salt = ?, password_nonce = ?, ` +
`password_master_key = ?, recovery_salt = ?, recovery_nonce = ?, ` +
`recovery_master_key = ? WHERE id = ?`

client, err := r.resolve()
if err != nil {
Expand All @@ -220,11 +238,12 @@ func (r *repository) UpdateUserPassword(
_, err = client.ExecContext(
ctx,
client.Rebind(query),
passwordHash, recoveryKey, passwordMasterKey, passwordNonce,
recoveryMasterKey, recoveryNonce, id,
material.PasswordSalt, material.PasswordNonce, material.PasswordMasterKey,
material.RecoverySalt, material.RecoveryNonce, material.RecoveryMasterKey,
id,
)
if err != nil {
return fmt.Errorf("failed to update user password: %w", err)
return fmt.Errorf("failed to update master key material: %w", err)
}
return nil
}
Loading
Loading