mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-28 21:52:26 +03:00
feat(core): one-time install tokens that register a host to a team
Installing on a host should be a command a person pastes, not a user session copied onto a machine. A token is issued for a team, spent by its first use, expires after an hour and is stored only as a digest, because it travels in that command and so lands in shell history. Redeeming is one conditional update inside the registration transaction: concurrent redemptions of one token register exactly one machine, and a registration that fails leaves the token unspent.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "install_token" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"created_by_user_id" char(30) NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"redeemed_at" timestamp with time zone,
|
||||
"machine_id" char(30)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "install_token_hash_unique" ON "install_token" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE INDEX "install_token_team_idx" ON "install_token" USING btree ("team_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +1,132 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784801002476,
|
||||
"tag": "0000_quick_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785312635128,
|
||||
"tag": "0001_opposite_senator_kelly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785379712946,
|
||||
"tag": "0002_light_mesmero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1785382013687,
|
||||
"tag": "0003_many_pyro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785588097470,
|
||||
"tag": "0004_remove_user_download_add_game_download",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785909838801,
|
||||
"tag": "0005_flaky_may_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786205230097,
|
||||
"tag": "0006_waitlist_verification_game_aliases",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1788460224524,
|
||||
"tag": "0007_box_session_team_notnull",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1788547836146,
|
||||
"tag": "0008_session_one_active_run_per_box",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1788555252186,
|
||||
"tag": "0009_email_is_the_root_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1788590292860,
|
||||
"tag": "0010_device_authorization_grant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788607804606,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788691753961,
|
||||
"tag": "0012_steam_enrolment_without_a_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788725541386,
|
||||
"tag": "0013_machine_endpoint_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1789680491539,
|
||||
"tag": "0014_machine_public_label",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1789762221718,
|
||||
"tag": "0015_organisation_owns_fleet_hardware",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1789765075037,
|
||||
"tag": "0016_burn_counters_and_rate_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784801002476,
|
||||
"tag": "0000_quick_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785312635128,
|
||||
"tag": "0001_opposite_senator_kelly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785379712946,
|
||||
"tag": "0002_light_mesmero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1785382013687,
|
||||
"tag": "0003_many_pyro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785588097470,
|
||||
"tag": "0004_remove_user_download_add_game_download",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785909838801,
|
||||
"tag": "0005_flaky_may_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786205230097,
|
||||
"tag": "0006_waitlist_verification_game_aliases",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1788460224524,
|
||||
"tag": "0007_box_session_team_notnull",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1788547836146,
|
||||
"tag": "0008_session_one_active_run_per_box",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1788555252186,
|
||||
"tag": "0009_email_is_the_root_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1788590292860,
|
||||
"tag": "0010_device_authorization_grant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788607804606,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788691753961,
|
||||
"tag": "0012_steam_enrolment_without_a_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788725541386,
|
||||
"tag": "0013_machine_endpoint_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1789680491539,
|
||||
"tag": "0014_machine_public_label",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1789762221718,
|
||||
"tag": "0015_organisation_owns_fleet_hardware",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1789765075037,
|
||||
"tag": "0016_burn_counters_and_rate_segments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1790573670492,
|
||||
"tag": "0017_install_token",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export namespace Identifier {
|
||||
userFingerprint: 'ufp',
|
||||
pairingCode: 'pai',
|
||||
machine: 'mch',
|
||||
installToken: 'mit',
|
||||
box: 'box',
|
||||
session: 'ses',
|
||||
accessToken: 'pat',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { MachineTable } from './machine.sql.js';
|
||||
|
||||
/**
|
||||
* A one-time credential that registers exactly one machine to one team.
|
||||
*
|
||||
* It exists so that installing on a host is a command a person pastes, rather
|
||||
* than a user session copied onto a machine. It travels in that command, so it
|
||||
* lands in shell history: that is why it is single-use, expires in minutes,
|
||||
* and is stored only as a digest.
|
||||
*/
|
||||
export const InstallTokenTable = pgTable(
|
||||
'install_token',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
// Who asked for it. They become the machine's owner, as they would have
|
||||
// by registering it with their own session.
|
||||
createdByUserId: ulid('created_by_user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: utc('expires_at').notNull(),
|
||||
redeemedAt: utc('redeemed_at'),
|
||||
// The machine it made. Null until redeemed; kept afterwards so a support
|
||||
// conversation can say which command produced which host.
|
||||
machineId: ulid('machine_id').references(() => MachineTable.id, { onDelete: 'set null' })
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('install_token_hash_unique').on(t.tokenHash),
|
||||
index('install_token_team_idx').on(t.teamId)
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { Fixtures } from '../db/fixtures.js';
|
||||
import { testDb } from '../db/test.js';
|
||||
import { InstallToken } from './install-token.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
async function newOwner(label: string) {
|
||||
const o = await Fixtures.owner(label);
|
||||
createdUserIds.push(o.userId);
|
||||
return o;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
describe('Install tokens', () => {
|
||||
test('a token registers one machine to its team, owned by whoever issued it', async () => {
|
||||
const owner = await newOwner('nit-ok');
|
||||
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
|
||||
expect(token.startsWith('nit_')).toBe(true);
|
||||
|
||||
const registered = await InstallToken.redeem({ token, label: 'host' });
|
||||
expect(registered?.secret.startsWith('msk_')).toBe(true);
|
||||
|
||||
const rows = await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
|
||||
expect(rows[0]!.team_id).toBe(owner.teamId);
|
||||
expect(rows[0]!.owner_user_id).toBe(owner.userId);
|
||||
|
||||
// Only the digest is kept, and the row records what it produced.
|
||||
const tok = await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
|
||||
expect(tok[0]!.token_hash).not.toBe(token);
|
||||
expect(tok[0]!.machine_id).toBe(registered!.machineId);
|
||||
});
|
||||
|
||||
test('a token is spent by its first use', async () => {
|
||||
const owner = await newOwner('nit-once');
|
||||
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
|
||||
expect(await InstallToken.redeem({ token, label: 'a' })).not.toBeNull();
|
||||
expect(await InstallToken.redeem({ token, label: 'b' })).toBeNull();
|
||||
});
|
||||
|
||||
test('two hosts racing one token register exactly one machine', async () => {
|
||||
const owner = await newOwner('nit-race');
|
||||
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, (_, i) => InstallToken.redeem({ token, label: `h${i}` }))
|
||||
);
|
||||
expect(results.filter(Boolean)).toHaveLength(1);
|
||||
const machines = await sql`select id from machine where team_id = ${owner.teamId}`;
|
||||
expect(machines).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('expired and unknown tokens are refused the same way', async () => {
|
||||
const owner = await newOwner('nit-expired');
|
||||
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
|
||||
await sql`update install_token set expires_at = now() - interval '1 minute' where team_id = ${owner.teamId}`;
|
||||
expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull();
|
||||
expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { and, eq, gt, isNull, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Machine } from './index.js';
|
||||
import { InstallTokenTable } from './install-token.sql.js';
|
||||
|
||||
export namespace InstallToken {
|
||||
/** 128 bits: guessing one inside its lifetime is not a strategy. */
|
||||
const TOKEN_BYTES = 16;
|
||||
|
||||
/**
|
||||
* How long a token lives. Long enough to copy a command, open a terminal on
|
||||
* another machine and run it; short enough that one found in shell history
|
||||
* tomorrow is worthless.
|
||||
*/
|
||||
export const TTL_MINUTES = 60;
|
||||
|
||||
function generate(): string {
|
||||
return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function digest(token: string): Promise<string> {
|
||||
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
|
||||
return Array.from(new Uint8Array(d))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Issue a token for `teamId`. The caller has already checked membership. */
|
||||
export const create = fn(z.object({ teamId: z.string(), userId: z.string() }), async (input) => {
|
||||
const token = generate();
|
||||
const tokenHash = await digest(token);
|
||||
const expiresAt = await Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(InstallTokenTable)
|
||||
.values({
|
||||
id: Identifier.ascending('installToken'),
|
||||
teamId: input.teamId,
|
||||
createdByUserId: input.userId,
|
||||
tokenHash,
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
|
||||
})
|
||||
.returning({ expiresAt: InstallTokenTable.expiresAt })
|
||||
.then((rows) => rows[0]!.expiresAt)
|
||||
);
|
||||
return { token, expiresAt };
|
||||
});
|
||||
|
||||
/**
|
||||
* Spend a token and register the machine it was issued for.
|
||||
*
|
||||
* Spending is one conditional UPDATE, so two hosts presenting the same token
|
||||
* at the same instant cannot both win — the loser sees no row and is refused.
|
||||
* Refusal is `null` for every reason (unknown, expired, already used), so the
|
||||
* answer teaches a caller nothing about which tokens exist.
|
||||
*/
|
||||
export const redeem = fn(
|
||||
z.object({ token: z.string(), label: z.string().min(1).max(64) }),
|
||||
async (input) => {
|
||||
const tokenHash = await digest(input.token);
|
||||
// One transaction, so a registration that fails leaves the token
|
||||
// unspent rather than burning the only copy the person has.
|
||||
return Database.transaction(async () => {
|
||||
const spent = await Database.use(async (tx) =>
|
||||
tx
|
||||
.update(InstallTokenTable)
|
||||
.set({ redeemedAt: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(InstallTokenTable.tokenHash, tokenHash),
|
||||
isNull(InstallTokenTable.redeemedAt),
|
||||
isNull(InstallTokenTable.timeDeleted),
|
||||
gt(InstallTokenTable.expiresAt, sql`now()`)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: InstallTokenTable.id,
|
||||
teamId: InstallTokenTable.teamId,
|
||||
userId: InstallTokenTable.createdByUserId
|
||||
})
|
||||
.then((rows) => rows.at(0) ?? null)
|
||||
);
|
||||
if (!spent) return null;
|
||||
|
||||
const machine = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: spent.userId,
|
||||
teamId: spent.teamId,
|
||||
label: input.label
|
||||
});
|
||||
|
||||
await Database.use(async (tx) =>
|
||||
tx
|
||||
.update(InstallTokenTable)
|
||||
.set({ machineId: machine.id })
|
||||
.where(eq(InstallTokenTable.id, spent.id))
|
||||
);
|
||||
|
||||
return { machineId: machine.id, slug: machine.slug, secret: machine.secret };
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user