pithos/hosted/rules.js
Local Dev d00bd369b7 Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.

Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00

93 lines
4.7 KiB
JavaScript

// Access rules for one hosted user: what s3d cannot express on its own.
//
// public anyone may read a drive or a folder (rights: read | read-list)
// link anyone holding the link may read one file until it expires
// grant whoever holds a gateway-minted S3 key may act on a drive or a
// folder with the rights given: read, add (new files only) or edit
//
// Prefixes are plain paths. Files under a rule's prefix are stored readable
// (the owner's Pithos converts them), because the people a rule is for have
// no decryption key.
import crypto from 'node:crypto';
export const RIGHTS = {
public: ['read', 'read-list'],
link: ['read'],
grant: ['read', 'add', 'edit'],
};
const id = (n = 12) => crypto.randomBytes(n).toString('base64url');
export function validateRule(input, buckets) {
const type = input.type;
if (!RIGHTS[type]) throw new Error('rule type must be public, link or grant');
const rights = input.rights || RIGHTS[type][0];
if (!RIGHTS[type].includes(rights)) throw new Error(`a ${type} rule allows ${RIGHTS[type].join(', ')}`);
const bucket = String(input.bucket || '');
if (buckets && !buckets.includes(bucket)) throw new Error(`no drive named "${bucket}"`);
const prefix = String(input.prefix || '');
if (prefix.startsWith('/') || prefix.includes('//')) throw new Error('bad folder path');
if (type === 'link' && (!prefix || prefix.endsWith('/'))) throw new Error('a link is for one file');
if (type !== 'link' && prefix && !prefix.endsWith('/')) throw new Error('rules apply to a drive or a folder (end it with /)');
let expires = null;
if (input.expires != null) {
expires = Number(input.expires);
if (!Number.isFinite(expires) || expires < Date.now()) throw new Error('the expiry must be in the future');
}
if (type === 'link' && !expires) throw new Error('links need an expiry');
const rule = { id: id(), type, bucket, prefix, rights, expires, label: String(input.label || '').slice(0, 120), createdAt: Date.now() };
if (type === 'link') rule.token = id(24);
if (type === 'grant') { rule.accessKeyId = 'PG' + crypto.randomBytes(9).toString('hex').toUpperCase(); rule.secretKey = id(30); }
return rule;
}
export const live = (r, now = Date.now()) => !r.expires || r.expires > now;
// What an S3 request wants to do, from method, query and key.
export function classify(method, query, key, headers = {}) {
if (key == null || key === '') {
if (method === 'GET' && !hasAny(query, ['policy', 'acl', 'cors', 'lifecycle', 'versioning', 'tagging', 'uploads'])) return { op: 'list' };
return { op: 'bucket-admin' };
}
if (headers['x-amz-copy-source']) return { op: 'copy' };
if (hasAny(query, ['acl', 'tagging', 'retention', 'legal-hold'])) return { op: 'object-admin' };
if (method === 'GET' || method === 'HEAD') return { op: 'read' };
if (method === 'PUT' || method === 'POST') return { op: 'write' };
if (method === 'DELETE') return { op: hasAny(query, ['uploadId']) ? 'write' : 'delete' };
return { op: 'object-admin' };
}
function hasAny(query, names) { return names.some((n) => Object.prototype.hasOwnProperty.call(query, n)); }
// Decides a grant-key request. Returns { allow, needsAbsent } or { allow:false, reason }.
// needsAbsent: an "add" grant may only create keys that do not exist yet.
export function decideGrant(rule, bucket, op, key, listPrefix = '') {
if (!live(rule)) return { allow: false, reason: 'this key has expired' };
if (bucket !== rule.bucket) return { allow: false, reason: 'this key is for another drive' };
if (op === 'list') {
return listPrefix.startsWith(rule.prefix) ? { allow: true } : { allow: false, reason: `this key may only list ${rule.prefix || 'the drive'}` };
}
if (!String(key).startsWith(rule.prefix)) return { allow: false, reason: `this key only reaches ${rule.prefix || 'the drive'}` };
if (op === 'read') return { allow: true };
if (op === 'write') {
if (rule.rights === 'edit') return { allow: true };
if (rule.rights === 'add') return { allow: true, needsAbsent: true };
return { allow: false, reason: 'this key is read-only' };
}
if (op === 'delete') return rule.rights === 'edit' ? { allow: true } : { allow: false, reason: 'this key may not delete' };
return { allow: false, reason: 'not allowed with a shared key' };
}
// Longest live public rule covering bucket/key, or null.
export function publicRuleFor(rules, bucket, key) {
let best = null;
for (const r of rules) {
if (r.type !== 'public' || r.bucket !== bucket || !live(r) || !key.startsWith(r.prefix)) continue;
if (!best || r.prefix.length > best.prefix.length) best = r;
}
return best;
}
// Rules as the owner sees them; grant secrets stay in (the owner made them).
export function forOwner(rules) { return rules.filter((r) => live(r)); }