diff --git a/packages/backend/convex/boxPassword.ts b/packages/backend/convex/boxPassword.ts new file mode 100644 index 0000000..6f18cd9 --- /dev/null +++ b/packages/backend/convex/boxPassword.ts @@ -0,0 +1,30 @@ +// Pure validation for the box user's password, shared by the Node action and +// unit tests. Kept free of Convex imports so it tests without the harness. + +export type BoxPasswordValidation = + | { ok: true; value: string | null } + | { ok: false; error: string }; + +// null/'' is an explicit clear (sudo reverts to NOPASSWD). The applied value +// travels to `chpasswd` as a `user:password` stdin line, so control characters +// (including newline/tab) are rejected; colons are fine — chpasswd splits on +// the first colon only. +export const normalizeBoxPassword = ( + password: string | null | undefined, +): BoxPasswordValidation => { + if (password == null || password === '') return { ok: true, value: null }; + if (password.length < 8) { + return { ok: false, error: 'Password must be at least 8 characters.' }; + } + if (password.length > 128) { + return { ok: false, error: 'Password must be at most 128 characters.' }; + } + // eslint-disable-next-line no-control-regex -- rejecting control chars is the point + if (/[\u0000-\u001f\u007f]/.test(password)) { + return { + ok: false, + error: 'Password must not contain control characters.', + }; + } + return { ok: true, value: password }; +}; diff --git a/packages/backend/convex/boxSettings.ts b/packages/backend/convex/boxSettings.ts new file mode 100644 index 0000000..1533c24 --- /dev/null +++ b/packages/backend/convex/boxSettings.ts @@ -0,0 +1,77 @@ +import { v } from 'convex/values'; + +import { internalMutation, internalQuery, query } from './_generated/server'; +import { deriveHomeUsername, getRequiredUserId } from './model'; + +// Per-user box account settings. The password itself is write-only from the +// client's perspective: browsers only ever learn whether one is set; the +// plaintext flows exclusively to the worker via boxSettingsNode. + +export const hasMine = query({ + args: {}, + handler: async (ctx) => { + const ownerId = await getRequiredUserId(ctx); + const row = await ctx.db + .query('boxSettings') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .unique(); + return { hasPassword: Boolean(row?.boxPasswordEncrypted) }; + }, +}); + +// Resolve the authed caller's owner id + home username (same derivation as +// userEnvironment.getMine) for the Node action, which cannot touch ctx.db. +export const resolveOwnerInternal = internalQuery({ + args: {}, + handler: async (ctx) => { + const ownerId = await getRequiredUserId(ctx); + const [user, settings] = await Promise.all([ + ctx.db.get(ownerId), + ctx.db + .query('userEnvironment') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .unique(), + ]); + return { + ownerId, + username: settings?.homeUsername ?? deriveHomeUsername(user?.name), + }; + }, +}); + +// Worker-facing lookup at box creation. `.first()` (not `.unique()`): derived +// usernames are not guaranteed globally unique, and a throw here would brick +// box creation for both colliding users. +export const getByUsernameInternal = internalQuery({ + args: { username: v.string() }, + handler: async (ctx, args) => + await ctx.db + .query('boxSettings') + .withIndex('by_username', (q) => q.eq('username', args.username)) + .first(), +}); + +export const upsertMineInternal = internalMutation({ + args: { + ownerId: v.id('users'), + username: v.string(), + boxPasswordEncrypted: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query('boxSettings') + .withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId)) + .unique(); + const patch = { + username: args.username, + // Explicit undefined clears the field on patch (password removed). + boxPasswordEncrypted: args.boxPasswordEncrypted, + updatedAt: Date.now(), + }; + if (existing) { + await ctx.db.patch(existing._id, patch); + } else { + await ctx.db.insert('boxSettings', { ownerId: args.ownerId, ...patch }); + } + }, +}); diff --git a/packages/backend/convex/boxSettingsNode.ts b/packages/backend/convex/boxSettingsNode.ts new file mode 100644 index 0000000..8cdbd4c --- /dev/null +++ b/packages/backend/convex/boxSettingsNode.ts @@ -0,0 +1,45 @@ +'use node'; + +import { ConvexError, v } from 'convex/values'; + +import { internal } from './_generated/api'; +import { action } from './_generated/server'; +import { normalizeBoxPassword } from './boxPassword'; +import { decryptSecret, encryptSecret } from './secretCrypto'; +import { assertWorkerToken } from './workerAuth'; + +/** Authed: store (or clear, with null/'') the caller's box password. */ +export const setBoxPassword = action({ + args: { password: v.union(v.string(), v.null()) }, + handler: async (ctx, args): Promise<{ hasPassword: boolean }> => { + const normalized = normalizeBoxPassword(args.password); + if (!normalized.ok) throw new ConvexError(normalized.error); + const { ownerId, username } = await ctx.runQuery( + internal.boxSettings.resolveOwnerInternal, + {}, + ); + await ctx.runMutation(internal.boxSettings.upsertMineInternal, { + ownerId, + username, + boxPasswordEncrypted: + normalized.value == null ? undefined : encryptSecret(normalized.value), + }); + return { hasPassword: normalized.value != null }; + }, +}); + +/** Worker-facing: the stored password (decrypted) for a box username. */ +export const getBoxPasswordForWorker = action({ + args: { workerToken: v.string(), username: v.string() }, + handler: async (ctx, args): Promise<{ password: string | null }> => { + assertWorkerToken(args.workerToken); + const row = await ctx.runQuery(internal.boxSettings.getByUsernameInternal, { + username: args.username, + }); + return { + password: row?.boxPasswordEncrypted + ? decryptSecret(row.boxPasswordEncrypted) + : null, + }; + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index eefcb09..1d81a3d 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -376,6 +376,18 @@ const applicationTables = { setupCommand: v.optional(v.string()), updatedAt: v.number(), }).index('by_owner', ['ownerId']), + // Per-user box account settings (the Linux user inside spoon-box-). + boxSettings: defineTable({ + ownerId: v.id('users'), + // Resolved Spoon home username at the time the row was written — the + // worker looks the password up by this at box creation (usernames are not + // indexed anywhere else). + username: v.string(), + boxPasswordEncrypted: v.optional(v.string()), + updatedAt: v.number(), + }) + .index('by_owner', ['ownerId']) + .index('by_username', ['username']), aiProviderProfiles: defineTable({ ownerId: v.id('users'), name: v.string(), diff --git a/packages/backend/tests/unit/box-password.test.ts b/packages/backend/tests/unit/box-password.test.ts new file mode 100644 index 0000000..4f28e89 --- /dev/null +++ b/packages/backend/tests/unit/box-password.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeBoxPassword } from '../../convex/boxPassword'; + +describe('normalizeBoxPassword', () => { + it('treats null/empty as an explicit clear', () => { + expect(normalizeBoxPassword(null)).toEqual({ ok: true, value: null }); + expect(normalizeBoxPassword(undefined)).toEqual({ ok: true, value: null }); + expect(normalizeBoxPassword('')).toEqual({ ok: true, value: null }); + }); + + it('enforces the 8..128 length bounds', () => { + expect(normalizeBoxPassword('short')).toMatchObject({ ok: false }); + expect(normalizeBoxPassword('a'.repeat(7))).toMatchObject({ ok: false }); + expect(normalizeBoxPassword('a'.repeat(8))).toEqual({ + ok: true, + value: 'a'.repeat(8), + }); + expect(normalizeBoxPassword('a'.repeat(128))).toMatchObject({ ok: true }); + expect(normalizeBoxPassword('a'.repeat(129))).toMatchObject({ ok: false }); + }); + + it('rejects control characters that would corrupt the chpasswd stdin line', () => { + expect(normalizeBoxPassword('has\nnewline1')).toMatchObject({ ok: false }); + expect(normalizeBoxPassword('has\ttab-char1')).toMatchObject({ ok: false }); + }); + + it('allows colons and spaces (chpasswd splits on the first colon only)', () => { + expect(normalizeBoxPassword('pa:ss wo:rd')).toEqual({ + ok: true, + value: 'pa:ss wo:rd', + }); + }); +});