caldav/carddav: officer-caldav sidecar and the /dav door
first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.
radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.
two doors, because a browser should not speak dav:
/dav/* top-level, http basic against a scoped app password, every
verb and every dav header forwarded verbatim. this is what
davx5 and ios talk to. same reasoning as /api/vault being
mounted outside protectedRouter.
/api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.
the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.
new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.
.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.
verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,14 @@ export {
|
||||
recordPhotosProbe,
|
||||
} from './queries/photos';
|
||||
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
|
||||
export {
|
||||
listDavAppPasswords,
|
||||
createDavAppPassword,
|
||||
revokeDavAppPassword,
|
||||
deleteDavAppPassword,
|
||||
verifyDavAppPassword,
|
||||
} from './queries/dav';
|
||||
export type { DavAppPassword, DavAppPasswordView } from './queries/dav';
|
||||
export {
|
||||
getServiceConnection,
|
||||
getServiceCredentials,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db } from '../db';
|
||||
import { davAppPasswords } from '../schema/dav';
|
||||
|
||||
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
||||
|
||||
/** What the UI is allowed to see: everything except the hash. */
|
||||
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
|
||||
|
||||
const view = (row: DavAppPassword): DavAppPasswordView => {
|
||||
const { passwordHash: _hash, ...rest } = row;
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
|
||||
// into a phone by hand. Grouped into blocks of four for the same reason.
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
|
||||
function generateSecret(): string {
|
||||
const bytes = randomBytes(20);
|
||||
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
|
||||
// still fits in five readable blocks.
|
||||
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
|
||||
}
|
||||
|
||||
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(eq(davAppPasswords.userId, userId))
|
||||
.orderBy(desc(davAppPasswords.createdAt));
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
|
||||
* value is the only chance the owner gets to see it.
|
||||
*/
|
||||
export async function createDavAppPassword(
|
||||
userId: number,
|
||||
label: string,
|
||||
): Promise<{ entry: DavAppPasswordView; password: string }> {
|
||||
const password = generateSecret();
|
||||
const [row] = await db
|
||||
.insert(davAppPasswords)
|
||||
.values({
|
||||
userId,
|
||||
label,
|
||||
passwordHash: await argon2.hash(password),
|
||||
hint: password.slice(0, 8),
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new Error('failed to create dav app password');
|
||||
return { entry: view(row), password };
|
||||
}
|
||||
|
||||
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.update(davAppPasswords)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.delete(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Basic-auth credential, returning the owning user id.
|
||||
*
|
||||
* Every live credential is tried, because the username a DAV client sends is the account email, not a
|
||||
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
|
||||
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
|
||||
* UI should encourage deleting devices that are gone rather than accumulating them.
|
||||
*
|
||||
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
|
||||
* the column exists to answer "is this device still around", not to be an access log.
|
||||
*/
|
||||
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
|
||||
|
||||
for (const row of rows) {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await argon2.verify(row.passwordHash, password);
|
||||
} catch {
|
||||
ok = false; // a corrupt hash must not take the whole login path down
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
const now = Date.now();
|
||||
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
|
||||
await db
|
||||
.update(davAppPasswords)
|
||||
.set({ lastUsedAt: new Date(now) })
|
||||
.where(eq(davAppPasswords.id, row.id));
|
||||
}
|
||||
return row.userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
|
||||
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
||||
//
|
||||
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
|
||||
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
|
||||
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
|
||||
// credential a phone could carry is the account password itself — which then lives in that phone's
|
||||
// account manager in recoverable form, and in whatever backs the phone up.
|
||||
//
|
||||
// So: one password per device, revocable per device, hashed with argon2 like the account password and
|
||||
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
|
||||
//
|
||||
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
|
||||
// this table — an app password is not a session, and a device that syncs a calendar has no business
|
||||
// reaching the wallet or the vault.
|
||||
//
|
||||
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
|
||||
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
|
||||
// noticeable at all.
|
||||
export const davAppPasswords = pgTable(
|
||||
'dav_app_passwords',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
|
||||
label: text('label').notNull(),
|
||||
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
/**
|
||||
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
|
||||
* authenticate with, and it is the only way for the owner to match a row in this list against the
|
||||
* password saved on a device they are looking at.
|
||||
*/
|
||||
hint: text('hint').notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
|
||||
// being free and it being a sequential scan on every PROPFIND.
|
||||
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth';
|
||||
export * from './chat-events';
|
||||
export * from './dashboards';
|
||||
export * from './dav';
|
||||
export * from './email';
|
||||
export * from './headscale';
|
||||
export * from './invoiceshelf';
|
||||
|
||||
Reference in New Issue
Block a user