multistreaming: scenes/composition, installer build support, service updates
- multistreaming (new): RTMP ingest + multi-platform fan-out with pluggable providers (Twitch/YouTube/Kick/custom), zero-knowledge key vaults, Authelia OIDC auth, shared rooms with editor/streamer roles, single-use invites, per-account streaming grants, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing). - installer: support Dockerfile build in metadata (not just image) and RSA key generation for the Authelia OIDC JWKS. - authelia: add OIDC provider with portainer + multistreaming clients (public + PKCE). - services: remove allprox; add nginx-proxy-manager and portainer; update lldap; regenerate catalog.
This commit is contained in:
parent
bb754cdd8c
commit
187379de4e
106 changed files with 21391 additions and 286 deletions
151
services/multistreaming/test/foundation.test.js
vendored
Normal file
151
services/multistreaming/test/foundation.test.js
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { Store } = require('../src/store');
|
||||
const { Auth } = require('../src/auth');
|
||||
const { Grants } = require('../src/grants');
|
||||
|
||||
function tmpdir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ms-test-'));
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
const store = new Store(tmpdir());
|
||||
const auth = new Auth({ store, sessionSecret: 'test-secret' });
|
||||
const grants = new Grants({ ttlMs: 1000 });
|
||||
return { store, auth, grants };
|
||||
}
|
||||
|
||||
test('register/login issues a session tied to the user', () => {
|
||||
const { store, auth } = fresh();
|
||||
const u = auth.register({ username: 'alice', password: 'pw' });
|
||||
assert.ok(u);
|
||||
assert.equal(auth.verifyPassword('pw', store.findUserByUsername('alice')), true);
|
||||
assert.equal(auth.verifyPassword('nope', store.findUserByUsername('alice')), false);
|
||||
|
||||
const token = auth.issueToken(u.id);
|
||||
const sess = auth.verifyToken(token);
|
||||
assert.equal(sess.uid, u.id);
|
||||
assert.ok(sess.sid);
|
||||
assert.equal(auth.verifyToken('garbage.token'), null);
|
||||
});
|
||||
|
||||
test('rooms: owner role, invite accept, editor/streamer roles', () => {
|
||||
const { store, auth } = fresh();
|
||||
const alice = auth.register({ username: 'alice', password: 'pw' });
|
||||
const bob = auth.register({ username: 'bob', password: 'pw' });
|
||||
|
||||
const room = store.createRoom({ name: 'Collab', ownerId: alice.id });
|
||||
assert.equal(store.membership(room.id, alice.id).role, 'owner');
|
||||
|
||||
const invite = store.createInvite({ roomId: room.id, role: 'editor' });
|
||||
const m = store.acceptInvite(invite, bob.id);
|
||||
assert.equal(m.role, 'editor');
|
||||
|
||||
// bob is now in the room; a second invite for streamer upgrades nothing (already member).
|
||||
assert.equal(store.roomsFor(bob.id).length, 1);
|
||||
assert.equal(store.findInviteByToken(invite.token), null); // consumed
|
||||
});
|
||||
|
||||
test('accounts store only ciphertext; owners own them', () => {
|
||||
const { store } = fresh();
|
||||
const alice = store.createUser({ username: 'alice', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const bob = store.createUser({ username: 'bob', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const vault = store.createVault({ ownerId: alice.id, name: 'Main', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
|
||||
const acct = store.createAccount({
|
||||
ownerId: alice.id,
|
||||
vaultId: vault.id,
|
||||
provider: 'twitch',
|
||||
name: 'Twitch main',
|
||||
url: 'rtmp://live.twitch.tv/app',
|
||||
secretCiphertext: { iv: 'a', data: 'b' },
|
||||
});
|
||||
|
||||
assert.equal(store.findAccount(acct.id).secretCiphertext.iv, 'a');
|
||||
assert.equal(store.findAccount(acct.id).vaultId, vault.id);
|
||||
assert.equal(store.listAccountsFor(alice.id).length, 1);
|
||||
assert.equal(store.listAccountsFor(bob.id).length, 0);
|
||||
|
||||
// Server-side state never has the plaintext key (it was never passed in).
|
||||
const raw = JSON.stringify(store.state);
|
||||
assert.ok(!raw.includes('sk_plaintext'));
|
||||
});
|
||||
|
||||
test('grants: grant → get → revoke → expiry', async () => {
|
||||
const { grants } = fresh();
|
||||
grants.grant('acct-1', 'sk_abc', 'alice');
|
||||
assert.equal(grants.get('acct-1'), 'sk_abc');
|
||||
assert.deepEqual(grants.grantedAccountIds(), ['acct-1']);
|
||||
|
||||
grants.revoke('acct-1');
|
||||
assert.equal(grants.get('acct-1'), null);
|
||||
|
||||
grants.grant('acct-2', 'sk_def', 'alice');
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
assert.equal(grants.get('acct-2'), null); // expired
|
||||
assert.deepEqual(grants.grantedAccountIds(), []);
|
||||
|
||||
// revokeBy clears only that user's grants.
|
||||
grants.grant('a', '1', 'alice');
|
||||
grants.grant('b', '2', 'bob');
|
||||
grants.revokeBy('alice');
|
||||
assert.equal(grants.get('a'), null);
|
||||
assert.equal(grants.get('b'), '2');
|
||||
});
|
||||
|
||||
test('feed stream keys are unique and lookable', () => {
|
||||
const { store } = fresh();
|
||||
const u = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const room = store.createRoom({ name: 'R', ownerId: u.id });
|
||||
const f1 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam A' });
|
||||
const f2 = store.createFeed({ roomId: room.id, ownerId: u.id, name: 'Cam B' });
|
||||
assert.notEqual(f1.streamKey, f2.streamKey);
|
||||
assert.equal(store.findFeedByStreamKey(f1.streamKey).id, f1.id);
|
||||
});
|
||||
|
||||
test('account ciphertext is only on the owner row (data-level isolation)', () => {
|
||||
const { store } = fresh();
|
||||
const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const bob = store.createUser({ username: 'b', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const vault = store.createVault({ ownerId: alice.id, name: 'V', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
|
||||
store.createAccount({ ownerId: alice.id, vaultId: vault.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } });
|
||||
|
||||
// Only alice's account list has a row; bob's is empty, so there is no path
|
||||
// to alice's ciphertext from bob's session.
|
||||
assert.equal(store.listAccountsFor(alice.id).length, 1);
|
||||
assert.equal(store.listAccountsFor(bob.id).length, 0);
|
||||
// And the ciphertext is never stored as plaintext anywhere.
|
||||
assert.ok(!JSON.stringify(store.state).includes('sk_secret'));
|
||||
});
|
||||
|
||||
test('vaults: multiple per user, one default, removable', () => {
|
||||
const { store } = fresh();
|
||||
const alice = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
|
||||
const v1 = store.createVault({ ownerId: alice.id, name: 'Personal', salt: 's1', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
const v2 = store.createVault({ ownerId: alice.id, name: 'Work', salt: 's2', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
|
||||
assert.equal(store.listVaultsFor(alice.id).length, 2);
|
||||
// First vault becomes the default automatically.
|
||||
assert.equal(store.findUserById(alice.id).defaultVaultId, v1.id);
|
||||
|
||||
// Explicit default switch.
|
||||
store.setDefaultVault(alice.id, v2.id);
|
||||
assert.equal(store.findUserById(alice.id).defaultVaultId, v2.id);
|
||||
|
||||
// Removing a vault detaches its accounts (ciphertext becomes unusable).
|
||||
const acct = store.createAccount({ ownerId: alice.id, vaultId: v1.id, provider: 'twitch', name: 'T', url: 'u', secretCiphertext: { iv: 'x', data: 'y' } });
|
||||
store.removeVault(v1.id);
|
||||
assert.equal(store.listVaultsFor(alice.id).length, 1);
|
||||
assert.equal(store.findAccount(acct.id).vaultId, null);
|
||||
|
||||
// Can't set someone else's vault as default.
|
||||
const mallory = store.createUser({ username: 'm', passwordHash: 'x', passwordSalt: 'y' });
|
||||
assert.equal(store.setDefaultVault(mallory.id, v2.id), null);
|
||||
});
|
||||
21
services/multistreaming/test/oidc.test.js
Normal file
21
services/multistreaming/test/oidc.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { randomToken, pkceChallenge } = require('../src/oidc');
|
||||
|
||||
test('PKCE S256 challenge is deterministic and 43 chars (base64url SHA-256)', () => {
|
||||
const verifier = 'abc123';
|
||||
const c1 = pkceChallenge(verifier);
|
||||
const c2 = pkceChallenge(verifier);
|
||||
assert.equal(c1, c2);
|
||||
assert.equal(c1.length, 43);
|
||||
assert.notEqual(c1, verifier);
|
||||
});
|
||||
|
||||
test('random tokens are unique and long enough', () => {
|
||||
const a = randomToken();
|
||||
const b = randomToken();
|
||||
assert.notEqual(a, b);
|
||||
assert.ok(a.length >= 43);
|
||||
});
|
||||
75
services/multistreaming/test/security.test.js
Normal file
75
services/multistreaming/test/security.test.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
test('invite tokens are 256-bit (64 hex chars)', () => {
|
||||
const { Store } = require('../src/store');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-sec-'));
|
||||
const store = new Store(dir);
|
||||
const owner = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const room = store.createRoom({ name: 'R', ownerId: owner.id });
|
||||
const invite = store.createInvite({ roomId: room.id, role: 'streamer' });
|
||||
// 32 bytes → 64 hex chars.
|
||||
assert.equal(invite.token.length, 64);
|
||||
assert.match(invite.token, /^[0-9a-f]{64}$/);
|
||||
// Expiry is set in the future.
|
||||
assert.ok(invite.expiresAt > Date.now());
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('invite is single-use (consumed on accept)', () => {
|
||||
const { Store } = require('../src/store');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-sec-'));
|
||||
const store = new Store(dir);
|
||||
const owner = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const bob = store.createUser({ username: 'b', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const room = store.createRoom({ name: 'R', ownerId: owner.id });
|
||||
const invite = store.createInvite({ roomId: room.id, role: 'editor' });
|
||||
|
||||
assert.ok(store.findInviteByToken(invite.token));
|
||||
store.acceptInvite(invite, bob.id);
|
||||
// Consumed: a second lookup fails, and a third user can't reuse it.
|
||||
assert.equal(store.findInviteByToken(invite.token), null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('redactUrl hides the stream key', () => {
|
||||
// redactUrl is not exported; test the behavior through a fresh copy of the logic.
|
||||
function redactUrl(url) {
|
||||
const idx = url.lastIndexOf('/');
|
||||
if (idx <= 0) return url;
|
||||
return `${url.slice(0, idx)}/•••`;
|
||||
}
|
||||
assert.equal(redactUrl('rtmp://live.twitch.tv/app/live_abc123'), 'rtmp://live.twitch.tv/app/•••');
|
||||
assert.ok(!redactUrl('rtmp://live.twitch.tv/app/live_abc123').includes('live_abc123'));
|
||||
});
|
||||
|
||||
test('account secret ciphertext is never stored plaintext and key never in logs', () => {
|
||||
const { Store } = require('../src/store');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-sec-'));
|
||||
const store = new Store(dir);
|
||||
const owner = store.createUser({ username: 'a', passwordHash: 'x', passwordSalt: 'y' });
|
||||
const vault = store.createVault({ ownerId: owner.id, name: 'V', salt: 's', serverWrapped: { iv: 'i', data: 'd' } });
|
||||
store.createAccount({
|
||||
ownerId: owner.id,
|
||||
vaultId: vault.id,
|
||||
provider: 'twitch',
|
||||
name: 'T',
|
||||
url: 'rtmp://live.twitch.tv/app',
|
||||
secretCiphertext: { iv: 'iv-here', data: 'ct-here' },
|
||||
});
|
||||
const persisted = JSON.stringify(store.state);
|
||||
assert.ok(!persisted.includes('live_secret_key'), 'plaintext key must never be persisted');
|
||||
assert.ok(persisted.includes('ct-here'), 'only ciphertext is stored');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
81
services/multistreaming/test/vault.test.js
Normal file
81
services/multistreaming/test/vault.test.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
enroll,
|
||||
silentUnlock,
|
||||
recover,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
randomBytes,
|
||||
} = require('../src/vault');
|
||||
|
||||
const PASSWORD = 'correct horse battery staple';
|
||||
const AAD = 'fp:abc123|sess:xyz789';
|
||||
|
||||
test('enroll → silent unlock works with correct AAD', async () => {
|
||||
const deviceKey = randomBytes(32);
|
||||
const { serverWrapped, deviceWrapped, salt } = await enroll({
|
||||
password: PASSWORD,
|
||||
deviceKey,
|
||||
deviceAad: AAD,
|
||||
});
|
||||
|
||||
const vk = await silentUnlock(deviceKey, deviceWrapped, AAD);
|
||||
assert.equal(vk.length, 32);
|
||||
assert.ok(serverWrapped.iv && serverWrapped.data);
|
||||
assert.ok(salt);
|
||||
});
|
||||
|
||||
test('silent unlock fails if session/fingerprint AAD changes', async () => {
|
||||
const deviceKey = randomBytes(32);
|
||||
const { deviceWrapped } = await enroll({ password: PASSWORD, deviceKey, deviceAad: AAD });
|
||||
|
||||
await assert.rejects(
|
||||
silentUnlock(deviceKey, deviceWrapped, 'fp:abc123|sess:DIFFERENT'),
|
||||
/decrypt|operation/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('silent unlock fails with a different device key (copied blob)', async () => {
|
||||
const deviceKeyA = randomBytes(32);
|
||||
const deviceKeyB = randomBytes(32);
|
||||
const { deviceWrapped } = await enroll({ password: PASSWORD, deviceKey: deviceKeyA, deviceAad: AAD });
|
||||
|
||||
await assert.rejects(silentUnlock(deviceKeyB, deviceWrapped, AAD), /decrypt|operation/i);
|
||||
});
|
||||
|
||||
test('recover with correct password works (new device)', async () => {
|
||||
const { serverWrapped, salt } = await enroll({ password: PASSWORD });
|
||||
const vk = await recover(PASSWORD, salt, serverWrapped);
|
||||
assert.equal(vk.length, 32);
|
||||
});
|
||||
|
||||
test('recover with wrong password fails', async () => {
|
||||
const { serverWrapped, salt } = await enroll({ password: PASSWORD });
|
||||
await assert.rejects(recover('wrong password', salt, serverWrapped), /decrypt|operation/i);
|
||||
});
|
||||
|
||||
test('account secret round-trips, and fails with wrong account AAD', async () => {
|
||||
const { serverWrapped, salt } = await enroll({ password: PASSWORD });
|
||||
const vk = await recover(PASSWORD, salt, serverWrapped);
|
||||
|
||||
const wrapped = await encryptSecret(vk, 'sk_live_secret_key_123', 'acct:twitch-1');
|
||||
const secret = await decryptSecret(vk, wrapped, 'acct:twitch-1');
|
||||
assert.equal(secret, 'sk_live_secret_key_123');
|
||||
|
||||
// The same ciphertext can't be re-attributed to a different account.
|
||||
await assert.rejects(decryptSecret(vk, wrapped, 'acct:kick-2'), /decrypt|operation/i);
|
||||
});
|
||||
|
||||
test('server never sees plaintext: wrapped blobs contain no secret bytes', async () => {
|
||||
const deviceKey = randomBytes(32);
|
||||
const secret = 'sk_topsecret';
|
||||
const { serverWrapped } = await enroll({ password: PASSWORD, deviceKey, deviceAad: AAD });
|
||||
const vk = await silentUnlock(deviceKey, (await enroll({ password: PASSWORD, deviceKey, deviceAad: AAD })).deviceWrapped, AAD);
|
||||
const wrappedSecret = await encryptSecret(vk, secret, 'acct:1');
|
||||
|
||||
const allBlobs = JSON.stringify({ serverWrapped, wrappedSecret });
|
||||
assert.ok(!allBlobs.includes(secret), 'ciphertext must not leak the plaintext secret');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue