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.
93 lines
4.7 KiB
JavaScript
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)); }
|