openvibers/openvibe.sdk Commits
Support large staged Locker shares and restored key exports (#141)
Changes
@@@ -1,5 +1,11 @@# Changelog++## 0.101.0 (2026-11-08)++++- Add bounded `locker/server.inspectEnvelopePrefix` validation against an actual stored ciphertext byte count.++- Allow service-bound staged envelope receipts in share updates, with the existing recipient, nonce and atomic reseal checks.++- Allow Locker public/private key export after `importPersonKeys`, so a recovered locker can upload and share using the same WebCrypto operations.++## 0.100.0 (2026-11-08)- Add `openvibe-sdk/compute`: shared compute strategies, hard placement constraints, RAM-aware pool scaling, exact billing arithmetic and provider quote/lifecycle adapters. See `docs/compute.md` for gates and unsupported purchase paths.@@@ -2,7 +2,7 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.100.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.101.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.@@@ -11,7 +11,7 @@After v0.97.1 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.100.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.100.0",++ "version": "0.101.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.100.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.101.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.100.0';++const SDK_VERSION = '0.101.0';module.exports = {createClient,@@@ -1360,7 +1360,7 @@ async function exportPersonKeys(person) {const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));const message = fingerprintMessage(ecdhPublic, signingPublic);const signature = person.fingerprintSignature-- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));return {ecdh: ecdhPublic,signing: signingPublic,@@@ -1373,8 +1373,8 @@ async function exportPersonKeys(person) {* The private half of a person's keys, as it is stored: the two private keys as JWKs.*/async function exportPrivateKeys(person) {-- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);-- const signing = await subtle().exportKey('jwk', person.signing.privateKey);++ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);++ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);return { ecdh, signing };}@@@ -766,6 +766,7 @@"openvibe-sdk/locker/server": {"checkHeader": "function","inspectEnvelope": "function",++ "inspectEnvelopePrefix": "function","peopleSlotOk": "function"},"openvibe-sdk/locker/vectors": {@@@ -117,3 +117,20 @@ await recipientItem.accept(offer.transfer.id);The client also has `get`, `update`, `remove(subject)`, `requestAccess`, `dismiss(requestId)`, `decline(id)`, `cancel(id)`, and service-level `sharedWithMe` and `people('@name')`. Member additions/removals read the ETag and send a conditional PUT, so they fail visibly on concurrent ACL changes. Network failures on mutations are not automatically retried. Read again to determine the result before retrying. Server CJS, ESM and TypeScript are exported from `openvibe-sdk/share`; the browser export is disabled.Run `node test/share.test.js` for the database and HTTP security suite. It covers all audience/role combinations, hidden 404s, expiration, audited staff access, exact-name resolution/rate limits, owner notification, all transfer answers, keep_access, forwarded offers, concurrency, hook rollback, immutable audit, durable retry, schemas and the agent client against a real local HTTP server.++++## Streamed file reseals++++A service that stores large ciphertext uploads can support `envelope_ref` on `update` instead of embedding an++`envelope` in JSON. Existing callers are unchanged. Supply `resolveReseal(ref, viewer, reference)`, returning++`{ prefix, byteLength }` from a **complete, validated upload bound to that resource and authenticated owner**.++`prefix` contains the eight-byte envelope preamble and complete header (at most 65544 bytes); `byteLength` is++the actual counted ciphertext size. Never resolve arbitrary URLs or trust a client-declared size.++++The store checks the header, exact expected size, lock kind, fresh nonce and the departing people's slots with++the same checks as inline reseals. `onResealReference(ref, reference, tx)` then attaches that stored object and++consumes its receipt in the ACL transaction. The hook must recheck ownership, expiry, resource revision and++quota in that transaction. Failed ACL writes must leave the old content and ACL intact; abandoned uploads++must expire. A service without these hooks refuses receipts. The same request cannot carry both forms.++++`openvibe-sdk/locker/server.inspectEnvelopePrefix(prefix, actualByteLength)` exposes the bounded header and++size check for streaming storage adapters. It never authenticates ciphertext; only the client can do that.@@@ -2,6 +2,6 @@import cjs from '../src/locker/server.js';export const {-- inspectEnvelope, checkHeader, peopleSlotOk,++ inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.100.0",++ "version": "0.101.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.100.0",++ "version": "0.101.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.100.0",++ "version": "0.101.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.100.0';++const SDK_VERSION = '0.101.0';module.exports = {createClient,@@@ -121,7 +121,7 @@ async function exportPersonKeys(person) {const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));const message = fingerprintMessage(ecdhPublic, signingPublic);const signature = person.fingerprintSignature-- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));++ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));return {ecdh: ecdhPublic,signing: signingPublic,@@@ -134,8 +134,8 @@ async function exportPersonKeys(person) {* The private half of a person's keys, as it is stored: the two private keys as JWKs.*/async function exportPrivateKeys(person) {-- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);-- const signing = await subtle().exportKey('jwk', person.signing.privateKey);++ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);++ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);return { ecdh, signing };}@@@ -108,7 +108,7 @@ function checkHeader(header) {* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key* is needed: this is what a service runs on bytes it may never be able to read.*/--function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {++function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {const errors = [];const data = bytes instanceof Uint8Array ? bytes : null;if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };@@@ -126,7 +126,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {let header = null;try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }-- errors.push(...checkHeader(header));++ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };++ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }const chunkOffset = 8 + headerLength;let expectedBytes = null;if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE@@@ -138,7 +139,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;expectedBytes = chunkOffset + whole + last;-- if (data.length < expectedBytes) errors.push('the envelope is shorter than its own header says');++ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');}return {valid: errors.length === 0,@@@ -153,4 +154,19 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {};}--module.exports = { inspectEnvelope, checkHeader, peopleSlotOk };++function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }++++/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.++ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.++ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.++ */++function inspectEnvelopePrefix(prefix, totalBytes, options) {++ const info = inspectBytes(prefix, options, totalBytes);++ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {++ info.errors.push('the stored byte count does not match the envelope');++ }++ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');++ info.valid = info.errors.length === 0;++ return info;++}++module.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };@@@ -1,7 +1,7 @@'use strict';const { randomUUID } = require('node:crypto');const { newEventId } = require('../core/ids');--const { inspectEnvelope } = require('../locker/server');++const { inspectEnvelope, inspectEnvelopePrefix } = require('../locker/server');const { jwkThumbprint } = require('../locker/keys');const v = require('./validation');const { migrateShares } = require('./schema');@@@ -21,7 +21,7 @@ const memberShape = row => ({ subject: row.subject, role: row.role, ...(row.expiconst usableKey = jwk => !!jwk && jwk.kty === 'EC' && jwk.crv === 'P-256'&& typeof jwk.x === 'string' && typeof jwk.y === 'string';--function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {++function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, resolveReseal, onResealReference, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {if (!db || !db.tx || !network || !network.json || !events || !events.publish || typeof notify !== 'function') {throw new TypeError('createShares requires db, network.json, events.publish and notify');}@@@ -193,11 +193,21 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChif (!read.valid || read.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'A re-seal is a valid people-locked envelope');return bytes;}++ async function preparedEnvelope(ref, viewer, reference) {++ if (typeof reference !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(reference)) v.invalid();++ if (typeof resolveReseal !== 'function' || typeof onResealReference !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot attach a staged envelope');++ // This hook is trusted service code. It binds a stored, complete upload to this resource and actor.++ const stored = await resolveReseal(ref, viewer, reference);++ if (!stored || !(stored.prefix instanceof Uint8Array) || stored.prefix.length > 65544) v.fail(400, 'share.reseal_invalid', 'The staged header is invalid');++ const info = inspectEnvelopePrefix(stored.prefix, stored.byteLength);++ if (!info.valid || info.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'The staged upload is not a complete people-locked envelope');++ return { prepared: true, reference, info, length: stored.byteLength };++ }/** Stores a re-seal through the service's hook, inside the transaction and before the member goes. */async function reseal(t, ref, s, bytes, actor, gone) {if (s.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'Only a people-locked item is re-sealed');-- if (typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');-- const read = inspectEnvelope(bytes);++ if (bytes.prepared ? typeof onResealReference !== 'function' : typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');++ const read = bytes.prepared ? bytes.info : inspectEnvelope(bytes);// Every seal writes a new nonce base, and a re-seal wraps a new content key. The nonce base the// envelope already stored is the one to beat: the same value is that envelope sent again, and the// member who is leaving could still open what is served next.@@@ -224,7 +234,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChconst left = slotKey(m.key_slot);if (left && recipients.has(left)) v.fail(400, 'share.reseal_invalid', 'The re-sealed envelope still holds the slot of the member who is leaving');}-- await onReseal(ref, bytes, t);++ if (bytes.prepared) await onResealReference(ref, bytes.reference, t);++ else await onReseal(ref, bytes, t);await t.exec(`UPDATE shares SET reseal_nonce_base=$4 WHERE ${where}`, [...key(ref), read.header.nonce_base]);s.reseal_nonce_base = read.header.nonce_base;await log(t, ref, actor, 'lock.resealed', { removed: gone.map(m => m.subject), bytes: bytes.length });@@@ -314,7 +325,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh});}async function update(viewer, input, body, { ifMatch } = {}) {-- const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope']);++ const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope', 'envelope_ref']);if (!Object.keys(body).length) v.invalid();if (body.audience !== undefined) v.audience(body.audience);if (body.notify !== undefined && typeof body.notify !== 'boolean') v.invalid();@@@ -325,7 +336,9 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceChif (body.lock !== undefined && body.lock !== lock) v.fail(409, 'share.lock_fixed', 'How an item is sealed is set when it is created');if (body.audience === 'public' && lock !== 'none') v.fail(400, 'share.lock_public', 'A locked item cannot be public');const list = body.members === undefined ? undefined : await inputs(viewer, body.members, lock);-- const resealed = body.envelope === undefined ? null : sealed(body.envelope);++ if (body.envelope !== undefined && body.envelope_ref !== undefined) v.invalid();++ const resealed = body.envelope_ref !== undefined ? await preparedEnvelope(ref, viewer, body.envelope_ref)++ : body.envelope === undefined ? null : sealed(body.envelope);const notify = body.notify !== false;return mutate(viewer, ref, async (t, ref, s, actor, staff) => {if (ifMatch !== undefined && ifMatch !== `"${s.revision}"`) v.fail(412, 'share.changed', 'Sharing changed; reload and try again');@@@ -107,6 +107,12 @@ run([const published = await locker.exportPersonKeys(alice);assert.equal(published.fingerprint, alice.fingerprint);assert.equal(await locker.verifyPersonKeys(published), true);++ const privateBackup = await locker.exportPrivateKeys(alice);++ const restored = await locker.importPersonKeys(privateBackup);++ const restoredPublic = await locker.exportPersonKeys(restored);++ assert.equal(restoredPublic.fingerprint, published.fingerprint, 'an unlocked backup can publish its own public keys');++ assert.equal(await locker.verifyPersonKeys(restoredPublic), true);++ assert.deepEqual(await locker.exportPrivateKeys(restored), privateBackup);// And the published keys are the contract.const checked = contracts.validate('locker.public-keys', {subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++const assert = require('node:assert/strict');++const { createDb } = require('../src/db');++const { createShares } = require('../src/share');++const { inspectEnvelopePrefix } = require('../src/locker/server');++const locker = require('../src/locker');++(async () => {++ const db = createDb({ pglite: true });++ const subject = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };++ const other = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };++ const person = await locker.generatePersonKeys(), friend = await locker.generatePersonKeys();++ const friendPublic = await locker.exportPersonKeys(friend);++ const ref = { service: 'locker', kind: 'files', id: 'streamed-file' };++ const sealed = await locker.seal(new Uint8Array(9 * 1024 ** 2), { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }, { subject: other, publicKey: friend.ecdhPublic }] });++ const prefix = sealed.bytes.subarray(0, sealed.chunkOffset);++ assert.ok(inspectEnvelopePrefix(prefix, sealed.bytes.length).valid);++ assert.equal(inspectEnvelopePrefix(prefix, sealed.bytes.length - 1).valid, false);++ assert.equal(inspectEnvelopePrefix(prefix, sealed.bytes.length + 1).valid, false);++ assert.equal(inspectEnvelopePrefix(prefix.subarray(0, -1), sealed.bytes.length).valid, false);++ const stored = new Map([['upload-1', { prefix, byteLength: sealed.bytes.length }]]);++ let committed = 0, failCommit = false;++ const shares = createShares({ db, network: { json: async (req) => {++ if (req.path.endsWith('/keys')) return friendPublic;++ if (req.path.endsWith('resolve-batch')) return { results: { [subject.id]: { subject, username: 'owner' }, [other.id]: { subject: other, username: 'friend' } } };++ return { subject: other, username: 'friend' };++ } }, events: { publish: async () => {} }, notify: async () => {},++ resolveReseal: async (resource, viewer, receipt) => {++ assert.deepEqual(resource, ref); assert.deepEqual(viewer.subject, subject); return stored.get(receipt);++ }, onResealReference: async (_ref, receipt, tx) => {++ if (failCommit) throw new Error('storage conflict');++ await tx.exec('INSERT INTO attachments(receipt) VALUES ($1)', [receipt]); committed++;++ } });++ await shares.migrate(); await db.exec('CREATE TABLE attachments(receipt text PRIMARY KEY)');++ await shares.register(ref, { owner_subject: subject, lock: 'people', audience: 'people' });++ const member = { subject: other, role: 'viewer', key_slot: sealed.header.slots.people[1] };++ await assert.rejects(() => shares.update({ subject: other }, ref, { envelope_ref: 'upload-1' }), (e) => e.status === 404);++ await assert.rejects(() => shares.update({ subject }, ref, { envelope_ref: 'missing' }), (e) => e.code === 'share.reseal_invalid');++ await assert.rejects(() => shares.update({ subject }, ref, { envelope_ref: 'upload-1', envelope: 'x' }), (e) => e.status === 400);++ failCommit = true;++ await assert.rejects(() => shares.update({ subject }, ref, { members: [member], envelope_ref: 'upload-1' }), /storage conflict/);++ assert.equal(await shares.canView({ subject: other }, ref), false);++ failCommit = false;++ await shares.update({ subject }, ref, { members: [member], envelope_ref: 'upload-1' });++ assert.equal(committed, 1); assert.equal(await shares.canView({ subject: other }, ref), true);++ await assert.rejects(() => shares.update({ subject }, ref, { members: [], envelope_ref: 'upload-1' }), (e) => e.code === 'share.reseal_not_fresh');++ const remaining = await locker.seal('private again', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] });++ stored.set('upload-2', { prefix: remaining.bytes.subarray(0, remaining.chunkOffset), byteLength: remaining.bytes.length });++ await shares.update({ subject }, ref, { members: [], envelope_ref: 'upload-2' });++ assert.equal(committed, 2); assert.equal(await shares.canView({ subject: other }, ref), false);++ assert.equal(await db.value('SELECT COUNT(*)::int FROM attachments'), 2);++ await db.close(); console.log('share-staged: bounded inspection, authorized receipts, rollback, fresh-key removal');++})().catch((e) => { console.error(e); process.exitCode = 1; });@@@ -27,6 +27,9 @@ export interface EnvelopeInfo {/** Reads an envelope's header and checks its shape and its size. Nothing is decrypted. */export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?: number }): EnvelopeInfo;++/** Check a bounded header prefix against the actual complete stored ciphertext size. */++export function inspectEnvelopePrefix(prefix: Uint8Array, totalBytes: number, options?: { maxHeaderBytes?: number }): EnvelopeInfo;++/** The header's shape: what it says, and whether the slots match the lock it names. */export function checkHeader(header: unknown): string[];@@@ -159,9 +159,9 @@ export function rewrap(envelope: Envelope | Uint8Array, options?: RewrapOptions)/** A person's two key pairs, made in the browser. */export function generatePersonKeys(): Promise<PersonKeys>;/** The public half of a person's keys, as it is published. */--export function exportPersonKeys(person: PersonKeys): Promise<PublicKeys>;++export function exportPersonKeys(person: PersonKeys | Awaited<ReturnType<typeof importPersonKeys>>): Promise<PublicKeys>;/** The private half of a person's keys, as it is stored. */--export function exportPrivateKeys(person: PersonKeys): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;++export function exportPrivateKeys(person: PersonKeys | Awaited<ReturnType<typeof importPersonKeys>>): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;/** A person's keys, ready to use. */export function importPersonKeys(data: { ecdh?: Record<string, unknown>; signing?: Record<string, unknown>; ecdhPrivateKey?: CryptoKey; signingPrivateKey?: CryptoKey }): Promise<{ ecdhPrivateKey: CryptoKey; signingPrivateKey: CryptoKey; ecdhPublic: PublicKeyJwk; signingPublic: PublicKeyJwk }>;/** Checks a published set of keys: the signature and the eight words. */@@@ -65,6 +65,10 @@ export interface ShareOptions {* A service that allows people locks must pass it; otherwise a removal answers 409 share.reseal_unavailable.*/onReseal?(ref: ResourceRef, envelope: Uint8Array, tx: Tx): Promise<unknown>;++ /** Resolve a fully received ciphertext upload, bound to this resource and authenticated viewer. Count actual bytes, not a client declaration. */++ resolveReseal?(ref: ResourceRef, viewer: Viewer, reference: string): Promise<{ prefix: Uint8Array; byteLength: number }>;++ /** Atomically attach that upload and consume its reference. Recheck owner, resource, expiry and revision through tx. Never external I/O. */++ onResealReference?(ref: ResourceRef, reference: string, tx: Tx): Promise<unknown>;now?(): number;nameLimit?: number;nameWindowMs?: number;@@@ -80,7 +84,7 @@ export interface Shares {present(viewer: Viewer, ref: ResourceRef, share: Share): Promise<ShareView>;/** Exact @name → person (null when unknown, banned or deleted), on the same rate-limit budget as resolve(). */lookup(viewer: Viewer, name: string): Promise<PeopleLookup>;-- update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<Share>;++ update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string; envelope_ref?: string }, options?: { ifMatch?: string }): Promise<Share>;setAudience(viewer: Viewer, ref: ResourceRef, audience: Audience): Promise<Share>;addMembers(viewer: Viewer, ref: ResourceRef, members: Array<MemberInput | SubjectRef | string>): Promise<Share>;/** On a people-locked item, `envelope` is required: the content re-sealed for those who stay. */@@@ -102,7 +106,7 @@ export function migrateShares(db: Db): Promise<void>;export class ShareError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) }export interface ShareResourceClient {get(): Promise<ShareResponse>;-- update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<ShareResponse>;++ update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string; envelope_ref?: string }, options?: { ifMatch?: string }): Promise<ShareResponse>;setAudience(audience: Audience): Promise<ShareResponse>;add(target: string | SubjectRef, options?: { role?: Role; expires_at?: string }): Promise<ShareResponse>;remove(subject: SubjectRef): Promise<ShareResponse>;
@@ -1,5 +1,11 @@
@@ -1,5 +1,11 @@
# Changelog
# Changelog
+## 0.101.0 (2026-11-08)
+
+- Add bounded `locker/server.inspectEnvelopePrefix` validation against an actual stored ciphertext byte count.
+- Allow service-bound staged envelope receipts in share updates, with the existing recipient, nonce and atomic reseal checks.
+- Allow Locker public/private key export after `importPersonKeys`, so a recovered locker can upload and share using the same WebCrypto operations.
+
## 0.100.0 (2026-11-08)
## 0.100.0 (2026-11-08)
- Add `openvibe-sdk/compute`: shared compute strategies, hard placement constraints, RAM-aware pool scaling, exact billing arithmetic and provider quote/lifecycle adapters. See `docs/compute.md` for gates and unsupported purchase paths.
- Add `openvibe-sdk/compute`: shared compute strategies, hard placement constraints, RAM-aware pool scaling, exact billing arithmetic and provider quote/lifecycle adapters. See `docs/compute.md` for gates and unsupported purchase paths.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.100.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.101.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.100.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.100.0",
+ "version": "0.101.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.100.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.101.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.100.0';
+const SDK_VERSION = '0.101.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1360,7 +1360,7 @@ async function exportPersonKeys(person) {
@@ -1360,7 +1360,7 @@ async function exportPersonKeys(person) {
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const message = fingerprintMessage(ecdhPublic, signingPublic);
const message = fingerprintMessage(ecdhPublic, signingPublic);
const signature = person.fingerprintSignature
const signature = person.fingerprintSignature
- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));
+ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));
return {
return {
ecdh: ecdhPublic,
ecdh: ecdhPublic,
signing: signingPublic,
signing: signingPublic,
@@ -1373,8 +1373,8 @@ async function exportPersonKeys(person) {
@@ -1373,8 +1373,8 @@ async function exportPersonKeys(person) {
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
*/
*/
async function exportPrivateKeys(person) {
async function exportPrivateKeys(person) {
- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);
- const signing = await subtle().exportKey('jwk', person.signing.privateKey);
+ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);
+ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);
return { ecdh, signing };
return { ecdh, signing };
}
}
@@ -766,6 +766,7 @@
@@ -766,6 +766,7 @@
"openvibe-sdk/locker/server": {
"openvibe-sdk/locker/server": {
"checkHeader": "function",
"checkHeader": "function",
"inspectEnvelope": "function",
"inspectEnvelope": "function",
+ "inspectEnvelopePrefix": "function",
"peopleSlotOk": "function"
"peopleSlotOk": "function"
},
},
"openvibe-sdk/locker/vectors": {
"openvibe-sdk/locker/vectors": {
@@ -117,3 +117,20 @@ await recipientItem.accept(offer.transfer.id);
@@ -117,3 +117,20 @@ await recipientItem.accept(offer.transfer.id);
The client also has `get`, `update`, `remove(subject)`, `requestAccess`, `dismiss(requestId)`, `decline(id)`, `cancel(id)`, and service-level `sharedWithMe` and `people('@name')`. Member additions/removals read the ETag and send a conditional PUT, so they fail visibly on concurrent ACL changes. Network failures on mutations are not automatically retried. Read again to determine the result before retrying. Server CJS, ESM and TypeScript are exported from `openvibe-sdk/share`; the browser export is disabled.
The client also has `get`, `update`, `remove(subject)`, `requestAccess`, `dismiss(requestId)`, `decline(id)`, `cancel(id)`, and service-level `sharedWithMe` and `people('@name')`. Member additions/removals read the ETag and send a conditional PUT, so they fail visibly on concurrent ACL changes. Network failures on mutations are not automatically retried. Read again to determine the result before retrying. Server CJS, ESM and TypeScript are exported from `openvibe-sdk/share`; the browser export is disabled.
Run `node test/share.test.js` for the database and HTTP security suite. It covers all audience/role combinations, hidden 404s, expiration, audited staff access, exact-name resolution/rate limits, owner notification, all transfer answers, keep_access, forwarded offers, concurrency, hook rollback, immutable audit, durable retry, schemas and the agent client against a real local HTTP server.
Run `node test/share.test.js` for the database and HTTP security suite. It covers all audience/role combinations, hidden 404s, expiration, audited staff access, exact-name resolution/rate limits, owner notification, all transfer answers, keep_access, forwarded offers, concurrency, hook rollback, immutable audit, durable retry, schemas and the agent client against a real local HTTP server.
+
+## Streamed file reseals
+
+A service that stores large ciphertext uploads can support `envelope_ref` on `update` instead of embedding an
+`envelope` in JSON. Existing callers are unchanged. Supply `resolveReseal(ref, viewer, reference)`, returning
+`{ prefix, byteLength }` from a **complete, validated upload bound to that resource and authenticated owner**.
+`prefix` contains the eight-byte envelope preamble and complete header (at most 65544 bytes); `byteLength` is
+the actual counted ciphertext size. Never resolve arbitrary URLs or trust a client-declared size.
+
+The store checks the header, exact expected size, lock kind, fresh nonce and the departing people's slots with
+the same checks as inline reseals. `onResealReference(ref, reference, tx)` then attaches that stored object and
+consumes its receipt in the ACL transaction. The hook must recheck ownership, expiry, resource revision and
+quota in that transaction. Failed ACL writes must leave the old content and ACL intact; abandoned uploads
+must expire. A service without these hooks refuses receipts. The same request cannot carry both forms.
+
+`openvibe-sdk/locker/server.inspectEnvelopePrefix(prefix, actualByteLength)` exposes the bounded header and
+size check for streaming storage adapters. It never authenticates ciphertext; only the client can do that.
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/locker/server.js';
import cjs from '../src/locker/server.js';
export const {
export const {
- inspectEnvelope, checkHeader, peopleSlotOk,
+ inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.100.0",
+ "version": "0.101.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.100.0",
+ "version": "0.101.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.100.0",
+ "version": "0.101.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.100.0';
+const SDK_VERSION = '0.101.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -121,7 +121,7 @@ async function exportPersonKeys(person) {
@@ -121,7 +121,7 @@ async function exportPersonKeys(person) {
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const signingPublic = publicJwk(person.signingPublic || await subtle().exportKey('jwk', person.signing.publicKey));
const message = fingerprintMessage(ecdhPublic, signingPublic);
const message = fingerprintMessage(ecdhPublic, signingPublic);
const signature = person.fingerprintSignature
const signature = person.fingerprintSignature
- || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, person.signing.privateKey, message));
+ || new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, (person.signing?.privateKey || person.signingPrivateKey), message));
return {
return {
ecdh: ecdhPublic,
ecdh: ecdhPublic,
signing: signingPublic,
signing: signingPublic,
@@ -134,8 +134,8 @@ async function exportPersonKeys(person) {
@@ -134,8 +134,8 @@ async function exportPersonKeys(person) {
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
* The private half of a person's keys, as it is stored: the two private keys as JWKs.
*/
*/
async function exportPrivateKeys(person) {
async function exportPrivateKeys(person) {
- const ecdh = await subtle().exportKey('jwk', person.ecdh.privateKey);
- const signing = await subtle().exportKey('jwk', person.signing.privateKey);
+ const ecdh = await subtle().exportKey('jwk', person.ecdh?.privateKey || person.ecdhPrivateKey);
+ const signing = await subtle().exportKey('jwk', person.signing?.privateKey || person.signingPrivateKey);
return { ecdh, signing };
return { ecdh, signing };
}
}
@@ -108,7 +108,7 @@ function checkHeader(header) {
@@ -108,7 +108,7 @@ function checkHeader(header) {
* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
* Reads an envelope's header and checks its shape and its size. Nothing is decrypted and no key
* is needed: this is what a service runs on bytes it may never be able to read.
* is needed: this is what a service runs on bytes it may never be able to read.
*/
*/
-function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
+function inspectBytes(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}, totalBytes = bytes && bytes.length) {
const errors = [];
const errors = [];
const data = bytes instanceof Uint8Array ? bytes : null;
const data = bytes instanceof Uint8Array ? bytes : null;
if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
if (!data) return { valid: false, errors: ['the envelope is not bytes'], header: null };
@@ -126,7 +126,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -126,7 +126,8 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
let header = null;
let header = null;
try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
try { header = JSON.parse(fromUtf8(data.subarray(8, 8 + headerLength))); }
catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
catch { return { valid: false, errors: ['the envelope header is not JSON'], header: null }; }
- errors.push(...checkHeader(header));
+ if (!header || typeof header !== 'object' || Array.isArray(header)) return { valid: false, errors: ['the header is not an object'], header: null };
+ try { errors.push(...checkHeader(header)); } catch { errors.push('the slots are not an object'); }
const chunkOffset = 8 + headerLength;
const chunkOffset = 8 + headerLength;
let expectedBytes = null;
let expectedBytes = null;
if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
if (Number.isInteger(header.padded_size) && header.padded_size >= MIN_PADDED_SIZE
@@ -138,7 +139,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -138,7 +139,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
const whole = (chunks - 1) * (frame + CHUNK_SIZE + TAG_SIZE);
const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
const last = frame + lastChunkSize(header.padded_size) + TAG_SIZE;
expectedBytes = chunkOffset + whole + last;
expectedBytes = chunkOffset + whole + last;
- if (data.length < expectedBytes) errors.push('the envelope is shorter than its own header says');
+ if (totalBytes < expectedBytes) errors.push('the envelope is shorter than its own header says');
}
}
return {
return {
valid: errors.length === 0,
valid: errors.length === 0,
@@ -153,4 +154,19 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
@@ -153,4 +154,19 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
};
};
}
}
-module.exports = { inspectEnvelope, checkHeader, peopleSlotOk };
+function inspectEnvelope(bytes, options) { return inspectBytes(bytes, options); }
+
+/** A streamed upload: inspect just its bounded header against the ACTUAL stored byte count.
+ * The caller must have counted and bounded the complete ciphertext first, not trust Content-Length.
+ * No chunks are read or authenticated here; as with inspectEnvelope, only a client can do that.
+ */
+function inspectEnvelopePrefix(prefix, totalBytes, options) {
+ const info = inspectBytes(prefix, options, totalBytes);
+ if (!Number.isSafeInteger(totalBytes) || !Number.isSafeInteger(info.expectedBytes) || totalBytes !== info.expectedBytes) {
+ info.errors.push('the stored byte count does not match the envelope');
+ }
+ if (info.header && !Number.isSafeInteger(info.header.padded_size)) info.errors.push('the padded size is not a safe integer');
+ info.valid = info.errors.length === 0;
+ return info;
+}
+module.exports = { inspectEnvelope, inspectEnvelopePrefix, checkHeader, peopleSlotOk };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
const { randomUUID } = require('node:crypto');
const { randomUUID } = require('node:crypto');
const { newEventId } = require('../core/ids');
const { newEventId } = require('../core/ids');
-const { inspectEnvelope } = require('../locker/server');
+const { inspectEnvelope, inspectEnvelopePrefix } = require('../locker/server');
const { jwkThumbprint } = require('../locker/keys');
const { jwkThumbprint } = require('../locker/keys');
const v = require('./validation');
const v = require('./validation');
const { migrateShares } = require('./schema');
const { migrateShares } = require('./schema');
@@ -21,7 +21,7 @@ const memberShape = row => ({ subject: row.subject, role: row.role, ...(row.expi
@@ -21,7 +21,7 @@ const memberShape = row => ({ subject: row.subject, role: row.role, ...(row.expi
const usableKey = jwk => !!jwk && jwk.kty === 'EC' && jwk.crv === 'P-256'
const usableKey = jwk => !!jwk && jwk.kty === 'EC' && jwk.crv === 'P-256'
&& typeof jwk.x === 'string' && typeof jwk.y === 'string';
&& typeof jwk.x === 'string' && typeof jwk.y === 'string';
-function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {
+function createShares({ db, network, events, notify, onOwnerChange, onAudienceChange, onReseal, resolveReseal, onResealReference, now = Date.now, nameLimit = 30, nameWindowMs = 60000 } = {}) {
if (!db || !db.tx || !network || !network.json || !events || !events.publish || typeof notify !== 'function') {
if (!db || !db.tx || !network || !network.json || !events || !events.publish || typeof notify !== 'function') {
throw new TypeError('createShares requires db, network.json, events.publish and notify');
throw new TypeError('createShares requires db, network.json, events.publish and notify');
}
}
@@ -193,11 +193,21 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -193,11 +193,21 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (!read.valid || read.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'A re-seal is a valid people-locked envelope');
if (!read.valid || read.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'A re-seal is a valid people-locked envelope');
return bytes;
return bytes;
}
}
+ async function preparedEnvelope(ref, viewer, reference) {
+ if (typeof reference !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(reference)) v.invalid();
+ if (typeof resolveReseal !== 'function' || typeof onResealReference !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot attach a staged envelope');
+ // This hook is trusted service code. It binds a stored, complete upload to this resource and actor.
+ const stored = await resolveReseal(ref, viewer, reference);
+ if (!stored || !(stored.prefix instanceof Uint8Array) || stored.prefix.length > 65544) v.fail(400, 'share.reseal_invalid', 'The staged header is invalid');
+ const info = inspectEnvelopePrefix(stored.prefix, stored.byteLength);
+ if (!info.valid || info.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'The staged upload is not a complete people-locked envelope');
+ return { prepared: true, reference, info, length: stored.byteLength };
+ }
/** Stores a re-seal through the service's hook, inside the transaction and before the member goes. */
/** Stores a re-seal through the service's hook, inside the transaction and before the member goes. */
async function reseal(t, ref, s, bytes, actor, gone) {
async function reseal(t, ref, s, bytes, actor, gone) {
if (s.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'Only a people-locked item is re-sealed');
if (s.lock !== 'people') v.fail(400, 'share.reseal_invalid', 'Only a people-locked item is re-sealed');
- if (typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');
- const read = inspectEnvelope(bytes);
+ if (bytes.prepared ? typeof onResealReference !== 'function' : typeof onReseal !== 'function') v.fail(409, 'share.reseal_unavailable', 'This service cannot store a re-sealed envelope');
+ const read = bytes.prepared ? bytes.info : inspectEnvelope(bytes);
// Every seal writes a new nonce base, and a re-seal wraps a new content key. The nonce base the
// Every seal writes a new nonce base, and a re-seal wraps a new content key. The nonce base the
// envelope already stored is the one to beat: the same value is that envelope sent again, and the
// envelope already stored is the one to beat: the same value is that envelope sent again, and the
// member who is leaving could still open what is served next.
// member who is leaving could still open what is served next.
@@ -224,7 +234,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -224,7 +234,8 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
const left = slotKey(m.key_slot);
const left = slotKey(m.key_slot);
if (left && recipients.has(left)) v.fail(400, 'share.reseal_invalid', 'The re-sealed envelope still holds the slot of the member who is leaving');
if (left && recipients.has(left)) v.fail(400, 'share.reseal_invalid', 'The re-sealed envelope still holds the slot of the member who is leaving');
}
}
- await onReseal(ref, bytes, t);
+ if (bytes.prepared) await onResealReference(ref, bytes.reference, t);
+ else await onReseal(ref, bytes, t);
await t.exec(`UPDATE shares SET reseal_nonce_base=$4 WHERE ${where}`, [...key(ref), read.header.nonce_base]);
await t.exec(`UPDATE shares SET reseal_nonce_base=$4 WHERE ${where}`, [...key(ref), read.header.nonce_base]);
s.reseal_nonce_base = read.header.nonce_base;
s.reseal_nonce_base = read.header.nonce_base;
await log(t, ref, actor, 'lock.resealed', { removed: gone.map(m => m.subject), bytes: bytes.length });
await log(t, ref, actor, 'lock.resealed', { removed: gone.map(m => m.subject), bytes: bytes.length });
@@ -314,7 +325,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -314,7 +325,7 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
});
});
}
}
async function update(viewer, input, body, { ifMatch } = {}) {
async function update(viewer, input, body, { ifMatch } = {}) {
- const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope']);
+ const ref = v.ref(input); v.object(body, ['audience', 'members', 'notify', 'lock', 'envelope', 'envelope_ref']);
if (!Object.keys(body).length) v.invalid();
if (!Object.keys(body).length) v.invalid();
if (body.audience !== undefined) v.audience(body.audience);
if (body.audience !== undefined) v.audience(body.audience);
if (body.notify !== undefined && typeof body.notify !== 'boolean') v.invalid();
if (body.notify !== undefined && typeof body.notify !== 'boolean') v.invalid();
@@ -325,7 +336,9 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
@@ -325,7 +336,9 @@ function createShares({ db, network, events, notify, onOwnerChange, onAudienceCh
if (body.lock !== undefined && body.lock !== lock) v.fail(409, 'share.lock_fixed', 'How an item is sealed is set when it is created');
if (body.lock !== undefined && body.lock !== lock) v.fail(409, 'share.lock_fixed', 'How an item is sealed is set when it is created');
if (body.audience === 'public' && lock !== 'none') v.fail(400, 'share.lock_public', 'A locked item cannot be public');
if (body.audience === 'public' && lock !== 'none') v.fail(400, 'share.lock_public', 'A locked item cannot be public');
const list = body.members === undefined ? undefined : await inputs(viewer, body.members, lock);
const list = body.members === undefined ? undefined : await inputs(viewer, body.members, lock);
- const resealed = body.envelope === undefined ? null : sealed(body.envelope);
+ if (body.envelope !== undefined && body.envelope_ref !== undefined) v.invalid();
+ const resealed = body.envelope_ref !== undefined ? await preparedEnvelope(ref, viewer, body.envelope_ref)
+ : body.envelope === undefined ? null : sealed(body.envelope);
const notify = body.notify !== false;
const notify = body.notify !== false;
return mutate(viewer, ref, async (t, ref, s, actor, staff) => {
return mutate(viewer, ref, async (t, ref, s, actor, staff) => {
if (ifMatch !== undefined && ifMatch !== `"${s.revision}"`) v.fail(412, 'share.changed', 'Sharing changed; reload and try again');
if (ifMatch !== undefined && ifMatch !== `"${s.revision}"`) v.fail(412, 'share.changed', 'Sharing changed; reload and try again');
@@ -107,6 +107,12 @@ run([
@@ -107,6 +107,12 @@ run([
const published = await locker.exportPersonKeys(alice);
const published = await locker.exportPersonKeys(alice);
assert.equal(published.fingerprint, alice.fingerprint);
assert.equal(published.fingerprint, alice.fingerprint);
assert.equal(await locker.verifyPersonKeys(published), true);
assert.equal(await locker.verifyPersonKeys(published), true);
+ const privateBackup = await locker.exportPrivateKeys(alice);
+ const restored = await locker.importPersonKeys(privateBackup);
+ const restoredPublic = await locker.exportPersonKeys(restored);
+ assert.equal(restoredPublic.fingerprint, published.fingerprint, 'an unlocked backup can publish its own public keys');
+ assert.equal(await locker.verifyPersonKeys(restoredPublic), true);
+ assert.deepEqual(await locker.exportPrivateKeys(restored), privateBackup);
// And the published keys are the contract.
// And the published keys are the contract.
const checked = contracts.validate('locker.public-keys', {
const checked = contracts.validate('locker.public-keys', {
subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { createDb } = require('../src/db');
+const { createShares } = require('../src/share');
+const { inspectEnvelopePrefix } = require('../src/locker/server');
+const locker = require('../src/locker');
+(async () => {
+ const db = createDb({ pglite: true });
+ const subject = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
+ const other = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };
+ const person = await locker.generatePersonKeys(), friend = await locker.generatePersonKeys();
+ const friendPublic = await locker.exportPersonKeys(friend);
+ const ref = { service: 'locker', kind: 'files', id: 'streamed-file' };
+ const sealed = await locker.seal(new Uint8Array(9 * 1024 ** 2), { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }, { subject: other, publicKey: friend.ecdhPublic }] });
+ const prefix = sealed.bytes.subarray(0, sealed.chunkOffset);
+ assert.ok(inspectEnvelopePrefix(prefix, sealed.bytes.length).valid);
+ assert.equal(inspectEnvelopePrefix(prefix, sealed.bytes.length - 1).valid, false);
+ assert.equal(inspectEnvelopePrefix(prefix, sealed.bytes.length + 1).valid, false);
+ assert.equal(inspectEnvelopePrefix(prefix.subarray(0, -1), sealed.bytes.length).valid, false);
+ const stored = new Map([['upload-1', { prefix, byteLength: sealed.bytes.length }]]);
+ let committed = 0, failCommit = false;
+ const shares = createShares({ db, network: { json: async (req) => {
+ if (req.path.endsWith('/keys')) return friendPublic;
+ if (req.path.endsWith('resolve-batch')) return { results: { [subject.id]: { subject, username: 'owner' }, [other.id]: { subject: other, username: 'friend' } } };
+ return { subject: other, username: 'friend' };
+ } }, events: { publish: async () => {} }, notify: async () => {},
+ resolveReseal: async (resource, viewer, receipt) => {
+ assert.deepEqual(resource, ref); assert.deepEqual(viewer.subject, subject); return stored.get(receipt);
+ }, onResealReference: async (_ref, receipt, tx) => {
+ if (failCommit) throw new Error('storage conflict');
+ await tx.exec('INSERT INTO attachments(receipt) VALUES ($1)', [receipt]); committed++;
+ } });
+ await shares.migrate(); await db.exec('CREATE TABLE attachments(receipt text PRIMARY KEY)');
+ await shares.register(ref, { owner_subject: subject, lock: 'people', audience: 'people' });
+ const member = { subject: other, role: 'viewer', key_slot: sealed.header.slots.people[1] };
+ await assert.rejects(() => shares.update({ subject: other }, ref, { envelope_ref: 'upload-1' }), (e) => e.status === 404);
+ await assert.rejects(() => shares.update({ subject }, ref, { envelope_ref: 'missing' }), (e) => e.code === 'share.reseal_invalid');
+ await assert.rejects(() => shares.update({ subject }, ref, { envelope_ref: 'upload-1', envelope: 'x' }), (e) => e.status === 400);
+ failCommit = true;
+ await assert.rejects(() => shares.update({ subject }, ref, { members: [member], envelope_ref: 'upload-1' }), /storage conflict/);
+ assert.equal(await shares.canView({ subject: other }, ref), false);
+ failCommit = false;
+ await shares.update({ subject }, ref, { members: [member], envelope_ref: 'upload-1' });
+ assert.equal(committed, 1); assert.equal(await shares.canView({ subject: other }, ref), true);
+ await assert.rejects(() => shares.update({ subject }, ref, { members: [], envelope_ref: 'upload-1' }), (e) => e.code === 'share.reseal_not_fresh');
+ const remaining = await locker.seal('private again', { lock: 'people', keys: [{ subject, publicKey: person.ecdhPublic }] });
+ stored.set('upload-2', { prefix: remaining.bytes.subarray(0, remaining.chunkOffset), byteLength: remaining.bytes.length });
+ await shares.update({ subject }, ref, { members: [], envelope_ref: 'upload-2' });
+ assert.equal(committed, 2); assert.equal(await shares.canView({ subject: other }, ref), false);
+ assert.equal(await db.value('SELECT COUNT(*)::int FROM attachments'), 2);
+ await db.close(); console.log('share-staged: bounded inspection, authorized receipts, rollback, fresh-key removal');
+})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -27,6 +27,9 @@ export interface EnvelopeInfo {
@@ -27,6 +27,9 @@ export interface EnvelopeInfo {
/** Reads an envelope's header and checks its shape and its size. Nothing is decrypted. */
/** Reads an envelope's header and checks its shape and its size. Nothing is decrypted. */
export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?: number }): EnvelopeInfo;
export function inspectEnvelope(bytes: Uint8Array, options?: { maxHeaderBytes?: number }): EnvelopeInfo;
+/** Check a bounded header prefix against the actual complete stored ciphertext size. */
+export function inspectEnvelopePrefix(prefix: Uint8Array, totalBytes: number, options?: { maxHeaderBytes?: number }): EnvelopeInfo;
+
/** The header's shape: what it says, and whether the slots match the lock it names. */
/** The header's shape: what it says, and whether the slots match the lock it names. */
export function checkHeader(header: unknown): string[];
export function checkHeader(header: unknown): string[];
@@ -159,9 +159,9 @@ export function rewrap(envelope: Envelope | Uint8Array, options?: RewrapOptions)
@@ -159,9 +159,9 @@ export function rewrap(envelope: Envelope | Uint8Array, options?: RewrapOptions)
/** A person's two key pairs, made in the browser. */
/** A person's two key pairs, made in the browser. */
export function generatePersonKeys(): Promise<PersonKeys>;
export function generatePersonKeys(): Promise<PersonKeys>;
/** The public half of a person's keys, as it is published. */
/** The public half of a person's keys, as it is published. */
-export function exportPersonKeys(person: PersonKeys): Promise<PublicKeys>;
+export function exportPersonKeys(person: PersonKeys | Awaited<ReturnType<typeof importPersonKeys>>): Promise<PublicKeys>;
/** The private half of a person's keys, as it is stored. */
/** The private half of a person's keys, as it is stored. */
-export function exportPrivateKeys(person: PersonKeys): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;
+export function exportPrivateKeys(person: PersonKeys | Awaited<ReturnType<typeof importPersonKeys>>): Promise<{ ecdh: Record<string, unknown>; signing: Record<string, unknown> }>;
/** A person's keys, ready to use. */
/** A person's keys, ready to use. */
export function importPersonKeys(data: { ecdh?: Record<string, unknown>; signing?: Record<string, unknown>; ecdhPrivateKey?: CryptoKey; signingPrivateKey?: CryptoKey }): Promise<{ ecdhPrivateKey: CryptoKey; signingPrivateKey: CryptoKey; ecdhPublic: PublicKeyJwk; signingPublic: PublicKeyJwk }>;
export function importPersonKeys(data: { ecdh?: Record<string, unknown>; signing?: Record<string, unknown>; ecdhPrivateKey?: CryptoKey; signingPrivateKey?: CryptoKey }): Promise<{ ecdhPrivateKey: CryptoKey; signingPrivateKey: CryptoKey; ecdhPublic: PublicKeyJwk; signingPublic: PublicKeyJwk }>;
/** Checks a published set of keys: the signature and the eight words. */
/** Checks a published set of keys: the signature and the eight words. */
@@ -65,6 +65,10 @@ export interface ShareOptions {
@@ -65,6 +65,10 @@ export interface ShareOptions {
* A service that allows people locks must pass it; otherwise a removal answers 409 share.reseal_unavailable.
* A service that allows people locks must pass it; otherwise a removal answers 409 share.reseal_unavailable.
*/
*/
onReseal?(ref: ResourceRef, envelope: Uint8Array, tx: Tx): Promise<unknown>;
onReseal?(ref: ResourceRef, envelope: Uint8Array, tx: Tx): Promise<unknown>;
+ /** Resolve a fully received ciphertext upload, bound to this resource and authenticated viewer. Count actual bytes, not a client declaration. */
+ resolveReseal?(ref: ResourceRef, viewer: Viewer, reference: string): Promise<{ prefix: Uint8Array; byteLength: number }>;
+ /** Atomically attach that upload and consume its reference. Recheck owner, resource, expiry and revision through tx. Never external I/O. */
+ onResealReference?(ref: ResourceRef, reference: string, tx: Tx): Promise<unknown>;
now?(): number;
now?(): number;
nameLimit?: number;
nameLimit?: number;
nameWindowMs?: number;
nameWindowMs?: number;
@@ -80,7 +84,7 @@ export interface Shares {
@@ -80,7 +84,7 @@ export interface Shares {
present(viewer: Viewer, ref: ResourceRef, share: Share): Promise<ShareView>;
present(viewer: Viewer, ref: ResourceRef, share: Share): Promise<ShareView>;
/** Exact @name → person (null when unknown, banned or deleted), on the same rate-limit budget as resolve(). */
/** Exact @name → person (null when unknown, banned or deleted), on the same rate-limit budget as resolve(). */
lookup(viewer: Viewer, name: string): Promise<PeopleLookup>;
lookup(viewer: Viewer, name: string): Promise<PeopleLookup>;
- update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<Share>;
+ update(viewer: Viewer, ref: ResourceRef, input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string; envelope_ref?: string }, options?: { ifMatch?: string }): Promise<Share>;
setAudience(viewer: Viewer, ref: ResourceRef, audience: Audience): Promise<Share>;
setAudience(viewer: Viewer, ref: ResourceRef, audience: Audience): Promise<Share>;
addMembers(viewer: Viewer, ref: ResourceRef, members: Array<MemberInput | SubjectRef | string>): Promise<Share>;
addMembers(viewer: Viewer, ref: ResourceRef, members: Array<MemberInput | SubjectRef | string>): Promise<Share>;
/** On a people-locked item, `envelope` is required: the content re-sealed for those who stay. */
/** On a people-locked item, `envelope` is required: the content re-sealed for those who stay. */
@@ -102,7 +106,7 @@ export function migrateShares(db: Db): Promise<void>;
@@ -102,7 +106,7 @@ export function migrateShares(db: Db): Promise<void>;
export class ShareError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) }
export class ShareError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) }
export interface ShareResourceClient {
export interface ShareResourceClient {
get(): Promise<ShareResponse>;
get(): Promise<ShareResponse>;
- update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string }, options?: { ifMatch?: string }): Promise<ShareResponse>;
+ update(input: { audience?: Audience; members?: MemberInput[]; notify?: boolean; lock?: Lock; envelope?: string; envelope_ref?: string }, options?: { ifMatch?: string }): Promise<ShareResponse>;
setAudience(audience: Audience): Promise<ShareResponse>;
setAudience(audience: Audience): Promise<ShareResponse>;
add(target: string | SubjectRef, options?: { role?: Role; expires_at?: string }): Promise<ShareResponse>;
add(target: string | SubjectRef, options?: { role?: Role; expires_at?: string }): Promise<ShareResponse>;
remove(subject: SubjectRef): Promise<ShareResponse>;
remove(subject: SubjectRef): Promise<ShareResponse>;