pithos-hosted: link a Theseus ID; a console session that cannot read data
A hosted account is its Ed25519 storage key, and that stays so: the key is
what owns and decrypts the data. A Theseus ID is added as a way to look at
the account (usage, sign-up, sign-out) from a browser that does not hold the
key, which is the account-level login DESIGN-theseus-id.md §11.3 asks for.
The owner links an ID with a normal session plus a Theseus ID sign-in for
project "pithos" (POST /hosted/v1/theseus-id). The ID then signs in to
POST /hosted/v1/console/session and gets a "console" token, which reaches
/hosted/v1/me, its own sign-out and the link status, and is refused
everything else: files, rules, moves and the forwarded owner API. Sessions
stored before scopes existed read as owner sessions. A move to a new ID in
Theseus carries the account along.
The verifier is TheseusID/lib's message.mjs and verify.mjs, copied verbatim
into hosted/theseus-id/ with libauth doing the curve maths, so the gateway
adds no dependency; a test fails if the copy drifts.
2026-10-06 20:34:52 +02:00
// Theseus ID on the gateway (DESIGN-theseus-id.md §11.3): the owner links an
// ID with a normal session; the ID then signs in to a console session that can
// see the account but never its files, rules or owner API.
//
// Sign-ins are built and signed with the reference library in TheseusID/, so
// this also proves the vendored verifier and its libauth curve maths agree
// with what Theseus produces.
import test from 'node:test' ;
import assert from 'node:assert/strict' ;
import fs from 'node:fs' ;
import os from 'node:os' ;
import path from 'node:path' ;
import http from 'node:http' ;
import crypto from 'node:crypto' ;
import { fileURLToPath , pathToFileURL } from 'node:url' ;
import { createHosted } from '../server.js' ;
import { challengeText } from '../auth.js' ;
import { verifyAddress } from '../theseus-id/index.js' ;
const here = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const tidDir = path . join ( here , '..' , '..' , '..' , 'TheseusID' ) ;
const haveRef = fs . existsSync ( path . join ( tidDir , 'node_modules' , '@noble' , 'curves' ) ) ;
const ref = haveRef ? await import ( pathToFileURL ( path . join ( tidDir , 'lib' , 'index.mjs' ) ) . href ) : null ;
const deps = haveRef ? await import ( pathToFileURL ( path . join ( tidDir , 'test' , 'deps.mjs' ) ) . href ) : null ;
test ( 'the vendored verifier is a verbatim copy of TheseusID/lib' , { skip : ! fs . existsSync ( tidDir ) && 'TheseusID/ not in this checkout' } , ( ) => {
for ( const f of [ 'message.mjs' , 'verify.mjs' ] ) {
assert . equal ( fs . readFileSync ( path . join ( here , '..' , 'theseus-id' , f ) , 'utf8' ) , fs . readFileSync ( path . join ( tidDir , 'lib' , f ) , 'utf8' ) , ` ${ f } drifted from TheseusID/lib; copy it again ` ) ;
}
} ) ;
const call = ( port , method , p , { body , token } = { } ) => new Promise ( ( resolve , reject ) => {
const data = body === undefined ? null : JSON . stringify ( body ) ;
const req = http . request ( { host : '127.0.0.1' , port , path : p , method , headers : { ... ( data ? { 'content-type' : 'application/json' , 'content-length' : Buffer . byteLength ( data ) } : { } ) , ... ( token ? { authorization : ` Bearer ${ token } ` } : { } ) } } , ( res ) => {
let b = '' ;
res . on ( 'data' , ( d ) => { b += d ; } ) ;
res . on ( 'end' , ( ) => { let j = null ; try { j = JSON . parse ( b ) ; } catch { } resolve ( { status : res . statusCode , body : j } ) ; } ) ;
} ) ;
req . on ( 'error' , reject ) ;
if ( data ) req . write ( data ) ;
req . end ( ) ;
} ) ;
const iso = ( ms ) => new Date ( ms ) . toISOString ( ) ;
function tidSignIn ( priv , nonce , { origin = 'theseus-addon://pithos' , projectId = 'pithos' } = { } ) {
const now = Date . now ( ) ;
const message = ref . buildMessage ( {
kind : 'signin' , origin , account : deps . crypto . account ( priv ) , uri : origin + '/' , chainId : 'tid:1' ,
projectId , nonce , issuedAt : iso ( now ) , expirationTime : iso ( now + 5 * 60_000 ) ,
} ) ;
return { message , signature : deps . crypto . sign ( priv , message ) } ;
}
test ( 'verifyAddress agrees with the reference library' , { skip : ! haveRef && 'TheseusID/node_modules missing' } , ( ) => {
const priv = crypto . randomBytes ( 32 ) ;
const s = tidSignIn ( priv , 'n' . repeat ( 20 ) ) ;
const me = deps . crypto . account ( priv ) ;
assert . equal ( verifyAddress ( s . message , s . signature , me ) , true ) ;
assert . equal ( verifyAddress ( s . message + 'x' , s . signature , me ) , false , 'other text' ) ;
assert . equal ( verifyAddress ( s . message , s . signature , deps . crypto . account ( crypto . randomBytes ( 32 ) ) ) , false , 'other account' ) ;
} ) ;
test ( 'link a Theseus ID, then a console session that cannot touch data' , { skip : ! haveRef && 'TheseusID/node_modules missing' } , async ( t ) => {
const root = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'pithos-tid-' ) ) ;
const port = 18900 + Math . floor ( Math . random ( ) * 200 ) ;
const h = await createHosted ( {
listen : ` 127.0.0.1: ${ port } ` , publicUrl : ` http://127.0.0.1: ${ port } ` , root , s3dBinary : null , portBase : 23200 , maxRunning : 1 , idleMinutes : 1 ,
gate : { invites : [ 'inv-1' , 'inv-2' ] } ,
} , ( ) => { } ) ;
t . after ( async ( ) => { await h . close ( ) ; fs . rmSync ( root , { recursive : true , force : true } ) ; } ) ;
// An owner: an Ed25519 key, signed up with an invite.
const { publicKey , privateKey } = crypto . generateKeyPairSync ( 'ed25519' ) ;
const pubkey = publicKey . export ( { format : 'der' , type : 'spki' } ) . subarray ( 12 ) . toString ( 'hex' ) ;
const ownerSession = async ( signup ) => {
const c = ( await call ( port , 'GET' , '/hosted/v1/challenge' ) ) . body ;
const signature = crypto . sign ( null , Buffer . from ( challengeText ( ` http://127.0.0.1: ${ port } ` , c . nonce ) ) , privateKey ) . toString ( 'base64' ) ;
return call ( port , 'POST' , '/hosted/v1/session' , { body : { pubkey , nonce : c . nonce , signature , signup } } ) ;
} ;
const owner = ( await ownerSession ( { type : 'invite' , code : 'inv-1' } ) ) . body . token ;
assert . ok ( owner ) ;
const tidKey = crypto . randomBytes ( 32 ) ;
const tid = deps . crypto . account ( tidKey ) ;
const fresh = async ( opts ) => tidSignIn ( tidKey , ( await call ( port , 'GET' , '/hosted/v1/theseus-id/challenge' ) ) . body . nonce , opts ) ;
// Before linking, the ID has nothing to sign in to.
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/console/session' , { body : await fresh ( ) } ) ) . status , 404 ) ;
// Linking needs the owner session.
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/theseus-id' , { body : await fresh ( ) } ) ) . status , 401 ) ;
const linked = await call ( port , 'POST' , '/hosted/v1/theseus-id' , { body : await fresh ( ) , token : owner } ) ;
assert . equal ( linked . status , 200 ) ;
assert . equal ( linked . body . account , tid ) ;
assert . equal ( ( await call ( port , 'GET' , '/hosted/v1/theseus-id' , { token : owner } ) ) . body . account , tid ) ;
// A sign-in for another project, or made on an origin outside the list, is refused.
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/console/session' , { body : await fresh ( { projectId : 'hephaestus' } ) } ) ) . status , 401 ) ;
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/console/session' , { body : await fresh ( { origin : 'https://navigate.st' } ) } ) ) . status , 401 ) ;
// A nonce is good once.
const once = await fresh ( ) ;
const c1 = await call ( port , 'POST' , '/hosted/v1/console/session' , { body : once } ) ;
assert . equal ( c1 . status , 200 ) ;
assert . equal ( c1 . body . scope , 'console' ) ;
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/console/session' , { body : once } ) ) . status , 401 ) ;
// The console sees the account...
const consoleTok = c1 . body . token ;
const me = await call ( port , 'GET' , '/hosted/v1/me' , { token : consoleTok } ) ;
assert . equal ( me . status , 200 ) ;
assert . equal ( me . body . scope , 'console' ) ;
assert . equal ( me . body . theseusId , tid ) ;
// ...and nothing that touches data, rules, the owner API or the link itself.
for ( const [ m , p ] of [ [ 'GET' , '/hosted/v1/rules' ] , [ 'POST' , '/hosted/v1/data/checkout' ] , [ 'GET' , '/hosted/v1/data/file?path=x' ] , [ 'GET' , '/api/status' ] , [ 'GET' , '/api/keys' ] , [ 'DELETE' , '/hosted/v1/theseus-id' ] , [ 'POST' , '/hosted/v1/theseus-id' ] ] ) {
assert . equal ( ( await call ( port , m , p , { token : consoleTok , body : m === 'POST' ? { } : undefined } ) ) . status , 403 , ` ${ m } ${ p } ` ) ;
}
// It can sign itself out.
assert . equal ( ( await call ( port , 'DELETE' , '/hosted/v1/session' , { token : consoleTok } ) ) . status , 200 ) ;
assert . equal ( ( await call ( port , 'GET' , '/hosted/v1/me' , { token : consoleTok } ) ) . status , 401 ) ;
// Another account cannot take the same ID.
const other = crypto . generateKeyPairSync ( 'ed25519' ) ;
const otherPub = other . publicKey . export ( { format : 'der' , type : 'spki' } ) . subarray ( 12 ) . toString ( 'hex' ) ;
const oc = ( await call ( port , 'GET' , '/hosted/v1/challenge' ) ) . body ;
const os2 = await call ( port , 'POST' , '/hosted/v1/session' , { body : { pubkey : otherPub , nonce : oc . nonce , signature : crypto . sign ( null , Buffer . from ( challengeText ( ` http://127.0.0.1: ${ port } ` , oc . nonce ) ) , other . privateKey ) . toString ( 'base64' ) , signup : { type : 'invite' , code : 'inv-2' } } } ) ;
assert . equal ( os2 . status , 200 ) ;
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/theseus-id' , { body : await fresh ( ) , token : os2 . body . token } ) ) . status , 409 ) ;
// The owner unlinks; the ID no longer signs in.
assert . equal ( ( await call ( port , 'DELETE' , '/hosted/v1/theseus-id' , { token : owner } ) ) . status , 200 ) ;
assert . equal ( ( await call ( port , 'POST' , '/hosted/v1/console/session' , { body : await fresh ( ) } ) ) . status , 404 ) ;
// Old owner sessions (written before scopes) stay owner sessions.
assert . equal ( ( await call ( port , 'GET' , '/hosted/v1/rules' , { token : owner } ) ) . status , 200 ) ;
} ) ;
2026-10-06 20:43:14 +02:00
test ( 'the console page: three fixed files with a strict CSP' , async ( t ) => {
const root = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'pithos-console-' ) ) ;
const port = 19500 + Math . floor ( Math . random ( ) * 200 ) ;
const h = await createHosted ( {
listen : ` 127.0.0.1: ${ port } ` , publicUrl : ` http://127.0.0.1: ${ port } ` , root , s3dBinary : null , portBase : 23600 , maxRunning : 1 , idleMinutes : 1 , gate : { invites : [ ] } ,
} , ( ) => { } ) ;
t . after ( async ( ) => { await h . close ( ) ; fs . rmSync ( root , { recursive : true , force : true } ) ; } ) ;
const get = ( p ) => new Promise ( ( resolve , reject ) => http . get ( { host : '127.0.0.1' , port , path : p } , ( res ) => {
let b = '' ; res . on ( 'data' , ( d ) => { b += d ; } ) ; res . on ( 'end' , ( ) => resolve ( { status : res . statusCode , headers : res . headers , body : b } ) ) ;
} ) . on ( 'error' , reject ) ) ;
const page = await get ( '/console' ) ;
assert . equal ( page . status , 200 ) ;
assert . match ( page . headers [ 'content-type' ] , /text\/html/ ) ;
assert . match ( page . headers [ 'content-security-policy' ] , /default-src 'none'; script-src 'self'/ ) ;
assert . match ( page . headers [ 'content-security-policy' ] , /frame-ancestors 'none'/ ) ;
assert . match ( page . body , /\/console\/console\.js/ ) ;
assert . equal ( ( await get ( '/console/console.js' ) ) . status , 200 ) ;
assert . equal ( ( await get ( '/console/console.css' ) ) . status , 200 ) ;
for ( const p of [ '/console/' , '/console/index.html' , '/console/../server.js' , '/console/%2e%2e/server.js' ] ) assert . equal ( ( await get ( p ) ) . status , 404 , p ) ;
} ) ;