say when a stored token cannot be decrypted
The frame answered {"error":"internal error"} on every request. The cause was
AES-GCM failing with "Unsupported state or unable to authenticate data" — the
token in service_connections was encrypted under a key that no longer exists,
because the platform mints its encryption key on first use and does not back it
up. The same loss took slskd, memos and gitea credentials on this project's own
server earlier today.
It presented as a broken sidecar. connectionState reported `configured: true`
because a ROW exists, so the panel skipped the form and went to the frame,
where every call threw. Nothing offered a way to fix it, and the connection
form was unreachable precisely because it only appears when unconfigured.
Now a named failure. `UnreadableCredentials` is distinguished from every other
error — a database that is down keeps its own message, or a real fault would be
reported as "re-enter your token" — and the panel shows the form saying the key
changed, nothing else is lost, and the URL is still there.
Verified by the platform itself refusing to decrypt: this is the state that
machine has been in all day.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
invalidateAllGiteaConfigs,
|
||||
normalizeBase,
|
||||
probe,
|
||||
UnreadableCredentials,
|
||||
} from './upstream';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
@@ -109,6 +110,23 @@ async function isOwner(userId: number): Promise<boolean> {
|
||||
// The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees
|
||||
// with its public host, so links have to be rebased client-side — see retargetUrls.
|
||||
async function connectionState(userId: number): Promise<Response> {
|
||||
// Asked first, and only for its failure: a row whose token cannot be decrypted must NOT be reported as
|
||||
// configured. It would send the panel to the frame, which then throws on every request — which is
|
||||
// exactly what happened, and it reads as the sidecar being broken rather than as a token needing
|
||||
// re-entering.
|
||||
try {
|
||||
await getGiteaConfig(userId);
|
||||
} catch (err) {
|
||||
if (err instanceof UnreadableCredentials) {
|
||||
return Response.json({
|
||||
configured: false,
|
||||
unreadable: true,
|
||||
isOwner: await isOwner(userId),
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const connection = await getServiceConnection(userId, 'gitea');
|
||||
const instanceUrl = await getServiceInstanceUrl('gitea');
|
||||
return Response.json({
|
||||
@@ -316,6 +334,11 @@ const server = Bun.serve({
|
||||
} catch (err) {
|
||||
// Path only. A repository body is the owner's private code.
|
||||
console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
|
||||
// Named, because the remedy is specific and the generic message sends people to the logs. This one
|
||||
// is not a fault: a token was encrypted under a key that no longer exists.
|
||||
if (err instanceof UnreadableCredentials) {
|
||||
return Response.json({ error: err.message, unreadable: true }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
|
||||
|
||||
+29
-1
@@ -46,9 +46,37 @@ export function invalidateAllGiteaConfigs(): void {
|
||||
//
|
||||
// A member's URL is never read from their own row because it is never written there. That is what stops
|
||||
// a settings form from becoming a per-user SSRF hop.
|
||||
/**
|
||||
* Thrown when the stored token exists but cannot be decrypted.
|
||||
*
|
||||
* Not a hypothetical. The platform's encryption key is minted on first use and NOT backed up, so losing
|
||||
* the key file re-mints it and every previously encrypted column becomes unreadable — which happened on
|
||||
* this project's own server, taking slskd, memos and gitea credentials with it. AES-GCM reports it as
|
||||
* "Unsupported state or unable to authenticate data", a message that says nothing to whoever sees it.
|
||||
*
|
||||
* Distinguished from "not configured" because the remedy differs: nothing is broken, a token simply has
|
||||
* to be pasted again.
|
||||
*/
|
||||
export class UnreadableCredentials extends Error {
|
||||
constructor() {
|
||||
super('stored gitea credentials cannot be decrypted — the token needs entering again');
|
||||
this.name = 'UnreadableCredentials';
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGiteaConfig(userId: number): Promise<GiteaConfig | null> {
|
||||
if (cache.has(userId)) return cache.get(userId) ?? null;
|
||||
const row = await getResolvedServiceCredentials(userId, 'gitea');
|
||||
let row: Awaited<ReturnType<typeof getResolvedServiceCredentials>>;
|
||||
try {
|
||||
row = await getResolvedServiceCredentials(userId, 'gitea');
|
||||
} catch (err) {
|
||||
// Only the decrypt failure is translated. Anything else — a database that is down, a bug — must keep
|
||||
// its own error, or a real fault would be reported to the owner as "re-enter your token".
|
||||
if (err instanceof Error && /unable to authenticate data|unsupported state/i.test(err.message)) {
|
||||
throw new UnreadableCredentials();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
|
||||
cache.set(userId, config);
|
||||
return config;
|
||||
|
||||
+17
-6
@@ -19,6 +19,15 @@ export type ConnectionState = {
|
||||
url?: string | null;
|
||||
user?: string | null;
|
||||
isOwner?: boolean;
|
||||
/**
|
||||
* A token is stored but cannot be decrypted.
|
||||
*
|
||||
* The platform's encryption key is minted on first use and not backed up, so losing it re-mints one
|
||||
* and every previously encrypted column becomes unreadable. Reported separately from "not configured"
|
||||
* because the person needs telling that nothing is broken and their URL is still there — only the
|
||||
* token has to be pasted again.
|
||||
*/
|
||||
unreadable?: boolean;
|
||||
};
|
||||
|
||||
type Props = { state: ConnectionState; onSaved: () => void };
|
||||
@@ -49,11 +58,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-md flex-col justify-center gap-4 p-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Connect Gitea</h2>
|
||||
<h2 className="text-base font-semibold">{state.unreadable ? 'Re-enter your token' : 'Connect Gitea'}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{state.isOwner === false
|
||||
? 'Your own access token. The instance is already set.'
|
||||
: 'Where Gitea runs, and a token for it.'}
|
||||
{state.unreadable
|
||||
? "The stored token can no longer be read — this server's encryption key changed. Nothing else is lost; paste it again."
|
||||
: state.isOwner === false
|
||||
? 'Your own access token. The instance is already set.'
|
||||
: 'Where Gitea runs, and a token for it.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,13 +85,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(ev) => setToken(ev.target.value)}
|
||||
placeholder={state.configured ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
|
||||
placeholder={state.configured && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<Button onClick={save} disabled={busy || (!state.configured && !token)}>
|
||||
<Button onClick={save} disabled={busy || ((!state.configured || state.unreadable) && !token)}>
|
||||
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{state.configured ? 'Save' : 'Connect'}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user