2026-10-05 02:25:02 +02:00
// The browser app's client for Drives on Silent Mode. It is served from the
// gateway's own origin, so every call here is same-origin: sign-in and the
// account API under /hosted/v1 and /api, S3 at /<drive>/<key>. Names and
// contents are encrypted and decrypted here exactly as core/hosted.js does.
import * as C from './crypt.js' ;
import { sign , presign , objectPath , canonicalQuery } from './sigv4.js' ;
const subtle = globalThis . crypto . subtle ;
const b64 = ( bytes ) => btoa ( String . fromCharCode ( ... bytes ) ) ;
const unb64 = ( s ) => Uint8Array . from ( atob ( String ( s || '' ) ) , ( c ) => c . charCodeAt ( 0 ) ) ;
const hex = ( b ) => Array . from ( b , ( x ) => x . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
const PKCS8 _ED25519 = Uint8Array . from ( [ 0x30 , 0x2e , 0x02 , 0x01 , 0x00 , 0x30 , 0x05 , 0x06 , 0x03 , 0x2b , 0x65 , 0x70 , 0x04 , 0x22 , 0x04 , 0x20 ] ) ;
export class ApiError extends Error {
constructor ( status , message , data ) { super ( message ) ; this . status = status ; this . data = data ; }
}
// ---- key files -----------------------------------------------------------------------
// What a dropped or pasted file is: an owner key (Pithos key file, v1 secret or
// v2 browser key) or an S3 key ({endpoint, accessKey, secretKey, bucket}).
export async function readKeyFile ( text ) {
let o ;
try { o = typeof text === 'string' ? JSON . parse ( text ) : text ; } catch { throw new Error ( 'This is not a key file (it is not JSON).' ) ; }
if ( o && o . secret && ( o . v === 1 || o . v == null ) ) {
const secret = unb64 ( o . secret ) ;
if ( secret . length !== 32 ) throw new Error ( 'This Pithos key file is damaged.' ) ;
return {
kind : 'owner' ,
identitySeed : await C . hkdf ( secret , new Uint8Array ( 0 ) , 'pithos/hosted-identity/v1' , 32 ) ,
encryptionKey : await C . hkdf ( secret , new Uint8Array ( 0 ) , 'pithos/hosted-encryption/v1' , 32 ) ,
} ;
}
if ( o && o . v === 2 && o . identitySeed && o . encryptionKey ) {
const identitySeed = unb64 ( o . identitySeed ) ;
const encryptionKey = unb64 ( o . encryptionKey ) ;
if ( identitySeed . length !== 32 || encryptionKey . length !== 32 ) throw new Error ( 'This browser key file is damaged.' ) ;
return { kind : 'owner' , identitySeed , encryptionKey } ;
}
if ( o && o . accessKey && o . secretKey ) {
return { kind : 's3' , accessKeyId : String ( o . accessKey ) , secretKey : String ( o . secretKey ) , bucket : o . bucket ? String ( o . bucket ) : '' , endpoint : o . endpoint ? String ( o . endpoint ) : '' } ;
}
throw new Error ( 'This file is neither a Pithos key nor an S3 key (Export JSON) file.' ) ;
}
// ---- the gateway's account API -----------------------------------------------------------
export async function api ( method , path , { token , body } = { } ) {
const headers = { } ;
if ( token ) headers . authorization = ` Bearer ${ token } ` ;
if ( body !== undefined ) headers [ 'content-type' ] = 'application/json' ;
const res = await fetch ( path , { method , headers , body : body === undefined ? undefined : JSON . stringify ( body ) , cache : 'no-store' } ) ;
const text = await res . text ( ) ;
let data = null ;
try { data = text ? JSON . parse ( text ) : null ; } catch { data = { error : text . slice ( 0 , 300 ) } ; }
if ( ! res . ok ) throw new ApiError ( res . status , data ? . error || ` HTTP ${ res . status } ` , data ) ;
return data ;
}
async function identity ( seed ) {
2026-10-07 23:06:33 +02:00
let key ;
try { key = await subtle . importKey ( 'pkcs8' , C . concat ( PKCS8 _ED25519 , seed ) , { name : 'Ed25519' } , true , [ 'sign' ] ) ; }
catch ( e ) {
// Ed25519 in Web Crypto: Chrome 137+, Firefox 129+, Safari 17+; on Android, an up-to-date Android System WebView
if ( e ? . name === 'NotSupportedError' ) throw new Error ( 'This browser is too old to sign in (it has no Ed25519). Update it, or on Android update "Android System WebView", and try again.' ) ;
throw e ;
}
2026-10-05 02:25:02 +02:00
const jwk = await subtle . exportKey ( 'jwk' , key ) ;
const pub = C . unb64u ( jwk . x ) ;
return { pubkey : hex ( pub ) , sign : async ( msg ) => b64 ( new Uint8Array ( await subtle . sign ( 'Ed25519' , key , C . utf8 ( msg ) ) ) ) } ;
}
// Ed25519 sign-in. Returns { token, id, state }; accounts are made in Pithos.
export async function signIn ( identitySeed ) {
const id = await identity ( identitySeed ) ;
const { nonce , message } = await api ( 'GET' , '/hosted/v1/challenge' ) ;
try {
return await api ( 'POST' , '/hosted/v1/session' , { body : { pubkey : id . pubkey , nonce , signature : await id . sign ( message ) } } ) ;
} catch ( e ) {
if ( e . status === 404 && e . data ? . needsSignup ) throw new Error ( 'There is no account on Silent Mode for this key yet. Create it in Pithos (Accounts › On Silent Mode), then sign in here.' ) ;
throw e ;
}
}
export const signOut = ( token ) => api ( 'DELETE' , '/hosted/v1/session' , { token } ) . catch ( ( ) => { } ) ;
// The owner's S3 key: the first key of the first S3 user, as Pithos does.
export async function ownerS3 ( token ) {
const users = await api ( 'GET' , '/api/users' , { token } ) ;
const names = ( Array . isArray ( users ) ? users : users ? . users || [ ] ) . map ( ( u ) => u . name || u . username || u ) . filter ( Boolean ) ;
for ( const user of names ) {
const keys = await api ( 'GET' , ` /api/keys?user= ${ encodeURIComponent ( user ) } ` , { token } ) ;
if ( keys ? . length ) return { user , accessKeyId : keys [ 0 ] . accessKeyId , secretKey : keys [ 0 ] . secretKey } ;
}
throw new Error ( 'This account has no S3 keys yet. Create one in Pithos (Users & keys).' ) ;
}
// ---- S3, signed in the page ---------------------------------------------------------------
const xmlTag = ( el , name ) => el . getElementsByTagName ( name ) [ 0 ] ? . textContent ? ? '' ;
const xmlAll = ( doc , name ) => Array . from ( doc . getElementsByTagName ( name ) ) ;
const parseXml = ( text ) => new DOMParser ( ) . parseFromString ( text , 'application/xml' ) ;
async function s3Fail ( res ) {
const text = await res . text ( ) . catch ( ( ) => '' ) ;
let msg = ` HTTP ${ res . status } ` ;
try { const d = parseXml ( text ) ; msg = xmlTag ( d , 'Message' ) || xmlTag ( d , 'Code' ) || msg ; } catch { }
try { const j = JSON . parse ( text ) ; msg = j . error || msg ; } catch { }
return new ApiError ( res . status , msg ) ;
}
// A drive client. master: the 32-byte encryption key (null for an S3 key file,
// which reads and writes readable files only). rules: async () => sharing rules.
export class Drives {
constructor ( { accessKeyId , secretKey , master = null , rules = async ( ) => [ ] } ) {
this . creds = { host : location . host , origin : location . origin , accessKeyId , secretKey } ;
this . master = master ;
this . rules = rules ;
this . dks = new Map ( ) ;
}
async dk ( bucket ) {
if ( ! this . master ) return null ;
if ( ! this . dks . has ( bucket ) ) this . dks . set ( bucket , await C . driveKeys ( this . master , bucket ) ) ;
return this . dks . get ( bucket ) ;
}
async req ( method , path , { query = { } , headers = { } , body , ok = true } = { } ) {
const h = await sign ( this . creds , method , path , query , headers ) ;
const qs = canonicalQuery ( query ) ;
const res = await fetch ( path + ( qs ? '?' + qs : '' ) , { method , headers : h , body , cache : 'no-store' } ) ;
if ( ok && ! res . ok ) throw await s3Fail ( res ) ;
return res ;
}
async listBuckets ( ) {
const d = parseXml ( await ( await this . req ( 'GET' , '/' ) ) . text ( ) ) ;
return xmlAll ( d , 'Bucket' ) . map ( ( b ) => ( { name : xmlTag ( b , 'Name' ) , created : xmlTag ( b , 'CreationDate' ) } ) ) ;
}
async rawList ( bucket , { prefix = '' , delimiter = '/' , token , max = 1000 } ) {
const query = { 'list-type' : '2' , prefix , delimiter , 'max-keys' : String ( max ) } ;
if ( token ) query [ 'continuation-token' ] = token ;
const d = parseXml ( await ( await this . req ( 'GET' , objectPath ( bucket ) , { query } ) ) . text ( ) ) ;
return {
folders : xmlAll ( d , 'CommonPrefixes' ) . map ( ( p ) => xmlTag ( p , 'Prefix' ) ) ,
objects : xmlAll ( d , 'Contents' ) . map ( ( o ) => ( { key : xmlTag ( o , 'Key' ) , size : Number ( xmlTag ( o , 'Size' ) ) , modified : xmlTag ( o , 'LastModified' ) } ) ) ,
truncated : xmlTag ( d . documentElement , 'IsTruncated' ) === 'true' ,
nextToken : xmlTag ( d . documentElement , 'NextContinuationToken' ) ,
} ;
}
// One folder level, encrypted and readable names merged (core/hosted.js listObjects).
async list ( bucket , prefix = '' ) {
const dk = await this . dk ( bucket ) ;
const folders = new Map ( ) ;
const objects = new Map ( ) ;
const sources = dk && prefix ? [ await C . encryptPath ( dk , prefix ) , prefix ] : [ prefix ] ;
for ( const pfx of sources ) {
let token ;
do {
const page = await this . rawList ( bucket , { prefix : pfx , token } ) ;
for ( const f of page . folders ) {
const d = dk ? await C . decryptPath ( dk , f ) : { path : f , encrypted : false } ;
if ( d . path . startsWith ( prefix ) && d . path !== prefix ) folders . set ( d . path , { path : d . path , encrypted : d . encrypted , locked : ! dk && f . split ( '/' ) . some ( ( s ) => s . startsWith ( '~' ) ) } ) ;
}
for ( const o of page . objects ) {
const d = dk ? await C . decryptPath ( dk , o . key ) : { path : o . key , encrypted : false } ;
if ( ! d . path . startsWith ( prefix ) || d . path === prefix ) continue ;
if ( objects . has ( d . path ) && ! d . encrypted ) continue ;
const size = d . encrypted && ! d . path . endsWith ( '/' ) ? C . plainSize ( o . size ) : o . size ;
objects . set ( d . path , { key : d . path , size : size ? ? o . size , modified : o . modified , encrypted : d . encrypted , storageKey : o . key , locked : ! dk && o . key . split ( '/' ) . some ( ( s ) => s . startsWith ( '~' ) ) } ) ;
}
token = page . truncated ? page . nextToken : null ;
} while ( token ) ;
}
const order = ( a , b ) => ( a < b ? - 1 : a > b ? 1 : 0 ) ;
return {
folders : [ ... folders . values ( ) ] . sort ( ( a , b ) => order ( a . path , b . path ) ) ,
files : [ ... objects . values ( ) ] . filter ( ( o ) => ! o . key . endsWith ( '/' ) ) . sort ( ( a , b ) => order ( a . key , b . key ) ) ,
} ;
}
async storeAsPlain ( bucket , key ) {
if ( ! this . master ) return true ;
const now = Date . now ( ) ;
return ( await this . rules ( ) ) . some ( ( r ) => r . bucket === bucket && ( ! r . expires || r . expires > now ) && ( r . type === 'link' ? r . prefix === key : key . startsWith ( r . prefix ) ) ) ;
}
// Where a file lives: { encrypted, storageKey, size (plain) }.
async locate ( bucket , key ) {
const dk = await this . dk ( bucket ) ;
if ( dk ) {
const ek = await C . encryptPath ( dk , key ) ;
const head = await this . req ( 'HEAD' , objectPath ( bucket , ek ) , { ok : false } ) ;
if ( head . ok ) {
const total = Number ( head . headers . get ( 'content-length' ) ) ;
return { encrypted : true , storageKey : ek , cipherLen : total , size : C . plainSize ( total ) } ;
}
}
const head = await this . req ( 'HEAD' , objectPath ( bucket , key ) , { ok : false } ) ;
if ( ! head . ok ) throw new ApiError ( head . status , head . status === 404 ? 'This file is gone.' : ` HTTP ${ head . status } ` ) ;
return { encrypted : false , storageKey : key , size : Number ( head . headers . get ( 'content-length' ) ) } ;
}
// Plaintext as a stream of Uint8Array chunks (whole file, or [start, end]).
async * read ( bucket , key , range ) {
const at = await this . locate ( bucket , key ) ;
if ( ! at . encrypted ) {
const headers = range ? { range : ` bytes= ${ range . start } - ${ range . end ? ? '' } ` } : { } ;
const res = await this . req ( 'GET' , objectPath ( bucket , at . storageKey ) , { headers } ) ;
const reader = res . body . getReader ( ) ;
for ( ; ; ) { const { value , done } = await reader . read ( ) ; if ( done ) return ; yield value ; }
}
const dk = await this . dk ( bucket ) ;
const r = C . cipherRange ( at . cipherLen , range || null ) ;
if ( r . unsatisfiable ) return ;
const getBytes = async ( from , to ) => new Uint8Array ( await ( await this . req ( 'GET' , objectPath ( bucket , at . storageKey ) , { headers : { range : ` bytes= ${ from } - ${ to } ` } } ) ) . arrayBuffer ( ) ) ;
const header = await getBytes ( 0 , C . HEADER _LEN - 1 ) ;
const ctx = await C . bodyContext ( dk , key , header ) ;
const span = ctx . cs + C . TAG _LEN ;
// fetch a few chunks per request, open them one by one
const BATCH = 8 ;
let cache = { first : - 1 , bytes : null } ;
const readChunk = async ( i ) => {
if ( i < cache . first || i >= cache . first + BATCH || ! cache . bytes ) {
const from = C . HEADER _LEN + i * span ;
const to = Math . min ( at . cipherLen - 1 , from + BATCH * span - 1 ) ;
cache = { first : i , bytes : await getBytes ( from , to ) } ;
}
const off = ( i - cache . first ) * span ;
return cache . bytes . subarray ( off , Math . min ( cache . bytes . length , off + span ) ) ;
} ;
yield * C . decryptChunks ( ctx , r , readChunk ) ;
}
async blob ( bucket , key , type ) {
const parts = [ ] ;
for await ( const p of this . read ( bucket , key ) ) parts . push ( p ) ;
return new Blob ( parts , { type : type || guessType ( key ) } ) ;
}
// A link the page can hand to <audio>/<video>/<img> without decrypting:
// only for readable files (encrypted ones go through blob()).
presigned ( bucket , storageKey , seconds = 3600 ) {
return presign ( this . creds , bucket , storageKey , seconds ) ;
}
async write ( bucket , key , file , onProgress = ( ) => { } ) {
const dk = await this . dk ( bucket ) ;
if ( await this . storeAsPlain ( bucket , key ) ) {
await this . req ( 'PUT' , objectPath ( bucket , key ) , { headers : { 'content-type' : file . type || guessType ( key ) } , body : file } ) ;
if ( dk ) { const ek = await C . encryptPath ( dk , key ) ; if ( ek !== key ) await this . req ( 'DELETE' , objectPath ( bucket , ek ) , { ok : false } ) ; }
onProgress ( file . size ) ;
return ;
}
const ek = await C . encryptPath ( dk , key ) ;
const parts = [ ] ;
let done = 0 ;
for await ( const p of C . encryptBlob ( dk , key , file ) ) { parts . push ( p ) ; done = Math . min ( file . size , done + p . length ) ; onProgress ( done ) ; }
await this . req ( 'PUT' , objectPath ( bucket , ek ) , { headers : { 'content-type' : 'application/octet-stream' } , body : new Blob ( parts ) } ) ;
if ( ek !== key ) await this . req ( 'DELETE' , objectPath ( bucket , key ) , { ok : false } ) ;
}
async mkdir ( bucket , folder ) {
const dk = await this . dk ( bucket ) ;
const key = folder . endsWith ( '/' ) ? folder : folder + '/' ;
const store = dk && ! ( await this . storeAsPlain ( bucket , key ) ) ? await C . encryptPath ( dk , key ) : key ;
await this . req ( 'PUT' , objectPath ( bucket , store ) , { body : new Uint8Array ( 0 ) } ) ;
}
async remove ( bucket , key ) {
const dk = await this . dk ( bucket ) ;
if ( dk ) await this . req ( 'DELETE' , objectPath ( bucket , await C . encryptPath ( dk , key ) ) , { ok : false } ) ;
await this . req ( 'DELETE' , objectPath ( bucket , key ) , { ok : false } ) ;
}
async removeFolder ( bucket , prefix , onEach = ( ) => { } ) {
const all = [ ] ;
const walk = async ( p ) => {
const l = await this . list ( bucket , p ) ;
for ( const f of l . files ) all . push ( f ) ;
for ( const d of l . folders ) { await walk ( d . path ) ; all . push ( { key : d . path , storageKey : null } ) ; }
} ;
await walk ( prefix ) ;
for ( const f of all ) { await this . remove ( bucket , f . key ) ; onEach ( f . key ) ; }
await this . remove ( bucket , prefix ) ;
}
// Encrypted names and contents are bound to their path, so a rename is a
// decrypt-and-encrypt copy here, then a delete.
async rename ( bucket , from , to ) {
if ( from === to ) return ;
const blob = await this . blob ( bucket , from ) ;
await this . write ( bucket , to , blob ) ;
await this . remove ( bucket , from ) ;
}
}
const MIME = {
html : 'text/html' , htm : 'text/html' , txt : 'text/plain' , md : 'text/markdown' , css : 'text/css' , js : 'text/javascript' , json : 'application/json' ,
xml : 'application/xml' , csv : 'text/csv' , pdf : 'application/pdf' , zip : 'application/zip' , png : 'image/png' , jpg : 'image/jpeg' , jpeg : 'image/jpeg' ,
gif : 'image/gif' , webp : 'image/webp' , svg : 'image/svg+xml' , avif : 'image/avif' , mp3 : 'audio/mpeg' , ogg : 'audio/ogg' , oga : 'audio/ogg' , wav : 'audio/wav' ,
flac : 'audio/flac' , m4a : 'audio/mp4' , opus : 'audio/opus' , mp4 : 'video/mp4' , webm : 'video/webm' , mov : 'video/quicktime' ,
} ;
export const guessType = ( key ) => MIME [ String ( key ) . split ( '.' ) . pop ( ) . toLowerCase ( ) ] || 'application/octet-stream' ;
// ---- remember on this device ------------------------------------------------------------------
// The key file, sealed with a passphrase (PBKDF2-SHA256 600k + AES-GCM), in
// IndexedDB on this origin. Nothing usable is stored without the passphrase.
const DB = 'pithos-web' ;
function idb ( ) {
return new Promise ( ( resolve , reject ) => {
const r = indexedDB . open ( DB , 1 ) ;
r . onupgradeneeded = ( ) => r . result . createObjectStore ( 'kv' ) ;
r . onsuccess = ( ) => resolve ( r . result ) ;
r . onerror = ( ) => reject ( r . error ) ;
} ) ;
}
async function kv ( mode , fn ) {
const db = await idb ( ) ;
return new Promise ( ( resolve , reject ) => {
const tx = db . transaction ( 'kv' , mode ) ;
const req = fn ( tx . objectStore ( 'kv' ) ) ;
tx . oncomplete = ( ) => resolve ( req ? . result ) ;
tx . onerror = ( ) => reject ( tx . error ) ;
} ) ;
}
async function passKey ( pass , salt ) {
const base = await subtle . importKey ( 'raw' , C . utf8 ( pass ) , 'PBKDF2' , false , [ 'deriveKey' ] ) ;
return subtle . deriveKey ( { name : 'PBKDF2' , hash : 'SHA-256' , salt , iterations : 600000 } , base , { name : 'AES-GCM' , length : 256 } , false , [ 'encrypt' , 'decrypt' ] ) ;
}
export async function remember ( keyText , pass ) {
const salt = crypto . getRandomValues ( new Uint8Array ( 16 ) ) ;
const iv = crypto . getRandomValues ( new Uint8Array ( 12 ) ) ;
const ct = new Uint8Array ( await subtle . encrypt ( { name : 'AES-GCM' , iv } , await passKey ( pass , salt ) , C . utf8 ( keyText ) ) ) ;
await kv ( 'readwrite' , ( s ) => s . put ( { v : 1 , salt , iv , ct , saved : Date . now ( ) } , 'key' ) ) ;
}
export const remembered = ( ) => kv ( 'readonly' , ( s ) => s . get ( 'key' ) ) . catch ( ( ) => null ) ;
export async function recall ( pass ) {
const r = await remembered ( ) ;
if ( ! r ) return null ;
try { return new TextDecoder ( ) . decode ( await subtle . decrypt ( { name : 'AES-GCM' , iv : r . iv } , await passKey ( pass , r . salt ) , r . ct ) ) ; } catch { throw new Error ( 'Wrong passphrase.' ) ; }
}
export const forget = ( ) => kv ( 'readwrite' , ( s ) => s . delete ( 'key' ) ) . catch ( ( ) => { } ) ;