@novasamatech/statement-store 0.8.12 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crypto.js +2 -1
- package/dist/helpers.d.ts +0 -1
- package/dist/helpers.js +0 -3
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/model/session.d.ts +1 -1
- package/dist/model/session.js +1 -1
- package/dist/model/sessionAccount.js +2 -1
- package/dist/session/codec/decoder.d.ts +68 -0
- package/dist/session/codec/decoder.js +96 -0
- package/dist/session/codec/decoder.spec.d.ts +1 -0
- package/dist/session/codec/decoder.spec.js +161 -0
- package/dist/session/codec/envelope.d.ts +55 -0
- package/dist/session/codec/envelope.js +115 -0
- package/dist/session/codec/envelope.spec.d.ts +1 -0
- package/dist/session/codec/envelope.spec.js +114 -0
- package/dist/session/codec/incomingTopics.d.ts +46 -0
- package/dist/session/codec/incomingTopics.js +69 -0
- package/dist/session/codec/outgoingBody.d.ts +46 -0
- package/dist/session/codec/outgoingBody.js +64 -0
- package/dist/session/core.d.ts +58 -0
- package/dist/session/core.js +609 -0
- package/dist/session/encyption.js +7 -7
- package/dist/session/encyption.spec.d.ts +1 -0
- package/dist/session/encyption.spec.js +43 -0
- package/dist/session/messageMapper.d.ts +3 -3
- package/dist/session/messageMapper.js +8 -8
- package/dist/session/multiDeviceSession.d.ts +49 -0
- package/dist/session/multiDeviceSession.js +62 -0
- package/dist/session/multiDeviceSession.spec.d.ts +6 -0
- package/dist/session/multiDeviceSession.spec.js +354 -0
- package/dist/session/scale/statementData.d.ts +40 -0
- package/dist/session/scale/statementData.js +34 -1
- package/dist/session/session.d.ts +15 -5
- package/dist/session/session.js +31 -633
- package/dist/session/session.spec.js +24 -7
- package/dist/session/stateMachine.d.ts +135 -0
- package/dist/session/stateMachine.js +203 -0
- package/dist/session/stateMachine.spec.d.ts +1 -0
- package/dist/session/stateMachine.spec.js +276 -0
- package/package.json +4 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
2
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createEnvelope } from './envelope.js';
|
|
5
|
+
function createDevice() {
|
|
6
|
+
const encryptionPrivateKey = x25519.utils.randomSecretKey();
|
|
7
|
+
return {
|
|
8
|
+
statementAccountId: randomBytes(32),
|
|
9
|
+
encryptionPublicKey: x25519.getPublicKey(encryptionPrivateKey),
|
|
10
|
+
encryptionPrivateKey,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function envelopeFor(device) {
|
|
14
|
+
return createEnvelope({
|
|
15
|
+
ownStatementAccountId: device.statementAccountId,
|
|
16
|
+
ownEncryptionPrivateKey: device.encryptionPrivateKey,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const PLAINTEXT = new TextEncoder().encode('inner Request bytes');
|
|
20
|
+
describe('multi-device envelope', () => {
|
|
21
|
+
it('a recipient device opens the envelope addressed to it', () => {
|
|
22
|
+
const sender = createDevice();
|
|
23
|
+
const recipientA = createDevice();
|
|
24
|
+
const recipientB = createDevice();
|
|
25
|
+
const wrapped = envelopeFor(sender).wrap(PLAINTEXT, [recipientA, recipientB])._unsafeUnwrap();
|
|
26
|
+
expect(wrapped.devicesInfo).toHaveLength(2);
|
|
27
|
+
for (const recipient of [recipientA, recipientB]) {
|
|
28
|
+
const opened = envelopeFor(recipient)
|
|
29
|
+
.unwrapForOwnDevice(wrapped.encryptedPayload, wrapped.devicesInfo, sender.encryptionPublicKey)
|
|
30
|
+
._unsafeUnwrap();
|
|
31
|
+
expect(opened).toEqual(PLAINTEXT);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// The premise the whole "no client-side outbox" design rests on: the per-device wrap
|
|
35
|
+
// secret is x25519(senderPriv, recipientPub), so the SENDER can re-derive it for any
|
|
36
|
+
// recipient and read its own envelope back out of the statement store.
|
|
37
|
+
it('the sender reads back its own envelope via any recipient it wrapped for', () => {
|
|
38
|
+
const sender = createDevice();
|
|
39
|
+
const recipientA = createDevice();
|
|
40
|
+
const recipientB = createDevice();
|
|
41
|
+
const recipients = [recipientA, recipientB];
|
|
42
|
+
const senderEnvelope = envelopeFor(sender);
|
|
43
|
+
const wrapped = senderEnvelope.wrap(PLAINTEXT, recipients)._unsafeUnwrap();
|
|
44
|
+
expect(senderEnvelope.unwrapOwn(wrapped.encryptedPayload, wrapped.devicesInfo, recipients)._unsafeUnwrap()).toEqual(PLAINTEXT);
|
|
45
|
+
// …and with only the second recipient known, so the loop is genuinely per-device.
|
|
46
|
+
expect(senderEnvelope.unwrapOwn(wrapped.encryptedPayload, wrapped.devicesInfo, [recipientB])._unsafeUnwrap()).toEqual(PLAINTEXT);
|
|
47
|
+
});
|
|
48
|
+
it('skips a stale roster entry and opens via a device that still matches', () => {
|
|
49
|
+
const sender = createDevice();
|
|
50
|
+
const live = createDevice();
|
|
51
|
+
// Same account id as `live`, but a rotated encryption key — its unwrap must fail and
|
|
52
|
+
// the loop must keep going rather than give up on the first mismatch.
|
|
53
|
+
const rotated = createDevice();
|
|
54
|
+
const stale = {
|
|
55
|
+
statementAccountId: rotated.statementAccountId,
|
|
56
|
+
encryptionPublicKey: createDevice().encryptionPublicKey,
|
|
57
|
+
};
|
|
58
|
+
const senderEnvelope = envelopeFor(sender);
|
|
59
|
+
const wrapped = senderEnvelope.wrap(PLAINTEXT, [rotated, live])._unsafeUnwrap();
|
|
60
|
+
expect(senderEnvelope.unwrapOwn(wrapped.encryptedPayload, wrapped.devicesInfo, [stale, live])._unsafeUnwrap()).toEqual(PLAINTEXT);
|
|
61
|
+
});
|
|
62
|
+
it('rejects a device that is not a recipient', () => {
|
|
63
|
+
const sender = createDevice();
|
|
64
|
+
const recipient = createDevice();
|
|
65
|
+
const outsider = createDevice();
|
|
66
|
+
const wrapped = envelopeFor(sender).wrap(PLAINTEXT, [recipient])._unsafeUnwrap();
|
|
67
|
+
expect(envelopeFor(outsider)
|
|
68
|
+
.unwrapForOwnDevice(wrapped.encryptedPayload, wrapped.devicesInfo, sender.encryptionPublicKey)
|
|
69
|
+
.isErr()).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('rejects the right device holding the wrong sender key', () => {
|
|
72
|
+
const sender = createDevice();
|
|
73
|
+
const impostor = createDevice();
|
|
74
|
+
const recipient = createDevice();
|
|
75
|
+
const wrapped = envelopeFor(sender).wrap(PLAINTEXT, [recipient])._unsafeUnwrap();
|
|
76
|
+
expect(envelopeFor(recipient)
|
|
77
|
+
.unwrapForOwnDevice(wrapped.encryptedPayload, wrapped.devicesInfo, impostor.encryptionPublicKey)
|
|
78
|
+
.isErr()).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it('rejects a tampered payload (AEAD tag)', () => {
|
|
81
|
+
const sender = createDevice();
|
|
82
|
+
const recipient = createDevice();
|
|
83
|
+
const wrapped = envelopeFor(sender).wrap(PLAINTEXT, [recipient])._unsafeUnwrap();
|
|
84
|
+
const tampered = Uint8Array.from(wrapped.encryptedPayload);
|
|
85
|
+
const last = tampered.length - 1;
|
|
86
|
+
tampered[last] = (tampered[last] ?? 0) ^ 0xff;
|
|
87
|
+
expect(envelopeFor(recipient).unwrapForOwnDevice(tampered, wrapped.devicesInfo, sender.encryptionPublicKey).isErr()).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
// Both fields are fixed-width on the wire and Bytes(32) zero-pads, so a short value would
|
|
90
|
+
// otherwise produce a statement addressed to a device that does not exist.
|
|
91
|
+
it.each([
|
|
92
|
+
[
|
|
93
|
+
'statementAccountId',
|
|
94
|
+
{ statementAccountId: randomBytes(20), encryptionPublicKey: createDevice().encryptionPublicKey },
|
|
95
|
+
],
|
|
96
|
+
['encryptionPublicKey', { statementAccountId: randomBytes(32), encryptionPublicKey: randomBytes(20) }],
|
|
97
|
+
])('refuses to wrap for a recipient with a malformed %s', (_field, recipient) => {
|
|
98
|
+
const result = envelopeFor(createDevice()).wrap(PLAINTEXT, [recipient]);
|
|
99
|
+
expect(result.isErr()).toBe(true);
|
|
100
|
+
expect(result._unsafeUnwrapErr().message).toContain('malformed');
|
|
101
|
+
});
|
|
102
|
+
it('refuses to wrap without recipients', () => {
|
|
103
|
+
expect(envelopeFor(createDevice()).wrap(PLAINTEXT, []).isErr()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
it('produces a fresh one-shot key per wrap', () => {
|
|
106
|
+
const sender = createDevice();
|
|
107
|
+
const recipient = createDevice();
|
|
108
|
+
const senderEnvelope = envelopeFor(sender);
|
|
109
|
+
const first = senderEnvelope.wrap(PLAINTEXT, [recipient])._unsafeUnwrap();
|
|
110
|
+
const second = senderEnvelope.wrap(PLAINTEXT, [recipient])._unsafeUnwrap();
|
|
111
|
+
expect(first.encryptedPayload).not.toEqual(second.encryptedPayload);
|
|
112
|
+
expect(first.devicesInfo[0].encryptedKey).not.toEqual(second.devicesInfo[0].encryptedKey);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The set of topics a session listens on, and the keys needed to read each one.
|
|
3
|
+
*
|
|
4
|
+
* Single-device sessions listen on exactly one topic — `SessionId(B, A)`, keyed by the
|
|
5
|
+
* pairwise shared secret. Multi-device sessions listen on one topic PER PEER DEVICE:
|
|
6
|
+
* `SessionId(D(B'), A)` keyed by `x25519(ownIdentityChatPriv, D(B').encPub)`, the
|
|
7
|
+
* receive-side mirror of the sender's `SessionId(D(A), B)` (mds.md).
|
|
8
|
+
*
|
|
9
|
+
* The set is observable because a peer's device roster changes at runtime
|
|
10
|
+
* (`deviceAdded`/`deviceRemoved`). The driver opens ONE `matchAny` subscription over the
|
|
11
|
+
* whole set and re-opens it when the set changes — rather than one subscription per
|
|
12
|
+
* device, which grows as contacts × devices.
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionAccount } from '../../model/sessionAccount.js';
|
|
15
|
+
import type { IncomingTopicSpec } from './decoder.js';
|
|
16
|
+
import type { DeviceTarget } from './envelope.js';
|
|
17
|
+
export type PeerRoster = {
|
|
18
|
+
current(): DeviceTarget[];
|
|
19
|
+
subscribe(callback: (devices: DeviceTarget[]) => void): VoidFunction;
|
|
20
|
+
};
|
|
21
|
+
export type IncomingTopics = {
|
|
22
|
+
current(): IncomingTopicSpec[];
|
|
23
|
+
/** Fires when the topic set changes. Returns an unsubscribe handle. */
|
|
24
|
+
subscribe(callback: (specs: IncomingTopicSpec[]) => void): VoidFunction;
|
|
25
|
+
};
|
|
26
|
+
/** One fixed topic — the single-device case. */
|
|
27
|
+
export declare function createStaticTopics(spec: IncomingTopicSpec): IncomingTopics;
|
|
28
|
+
/**
|
|
29
|
+
* One topic per peer device, recomputed whenever the roster changes.
|
|
30
|
+
*
|
|
31
|
+
* Derivation per device `D(B')`:
|
|
32
|
+
* K = x25519(ownIdentityChatPrivateKey, D(B').encryptionPublicKey)
|
|
33
|
+
* topic = SessionId(D(B'), A) — sender is the peer device, receiver is our identity
|
|
34
|
+
*/
|
|
35
|
+
export declare function createRosterTopics({ localIdentity, remotePin, ownIdentityChatPrivateKey, peerRoster, }: {
|
|
36
|
+
localIdentity: SessionAccount;
|
|
37
|
+
/**
|
|
38
|
+
* The peer IDENTITY's pin. A device inherits the pin of the identity it belongs to, so
|
|
39
|
+
* this is what goes in the sender slot of `SessionIdParam` — matching what the peer used
|
|
40
|
+
* when it published (Android `RealIncomingTopicsProviderFactory`, `pin = remoteAccount.pin`).
|
|
41
|
+
* Getting this wrong yields a topic the peer never writes to, and messages simply never arrive.
|
|
42
|
+
*/
|
|
43
|
+
remotePin: string | undefined;
|
|
44
|
+
ownIdentityChatPrivateKey: Uint8Array;
|
|
45
|
+
peerRoster: PeerRoster;
|
|
46
|
+
}): IncomingTopics;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The set of topics a session listens on, and the keys needed to read each one.
|
|
3
|
+
*
|
|
4
|
+
* Single-device sessions listen on exactly one topic — `SessionId(B, A)`, keyed by the
|
|
5
|
+
* pairwise shared secret. Multi-device sessions listen on one topic PER PEER DEVICE:
|
|
6
|
+
* `SessionId(D(B'), A)` keyed by `x25519(ownIdentityChatPriv, D(B').encPub)`, the
|
|
7
|
+
* receive-side mirror of the sender's `SessionId(D(A), B)` (mds.md).
|
|
8
|
+
*
|
|
9
|
+
* The set is observable because a peer's device roster changes at runtime
|
|
10
|
+
* (`deviceAdded`/`deviceRemoved`). The driver opens ONE `matchAny` subscription over the
|
|
11
|
+
* whole set and re-opens it when the set changes — rather than one subscription per
|
|
12
|
+
* device, which grows as contacts × devices.
|
|
13
|
+
*/
|
|
14
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
15
|
+
import { createSessionId } from '../../model/session.js';
|
|
16
|
+
import { createEncryption } from '../encyption.js';
|
|
17
|
+
import { isValidDevice } from './envelope.js';
|
|
18
|
+
/** One fixed topic — the single-device case. */
|
|
19
|
+
export function createStaticTopics(spec) {
|
|
20
|
+
const specs = [spec];
|
|
21
|
+
return {
|
|
22
|
+
current: () => specs,
|
|
23
|
+
subscribe: () => () => undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One topic per peer device, recomputed whenever the roster changes.
|
|
28
|
+
*
|
|
29
|
+
* Derivation per device `D(B')`:
|
|
30
|
+
* K = x25519(ownIdentityChatPrivateKey, D(B').encryptionPublicKey)
|
|
31
|
+
* topic = SessionId(D(B'), A) — sender is the peer device, receiver is our identity
|
|
32
|
+
*/
|
|
33
|
+
export function createRosterTopics({ localIdentity, remotePin, ownIdentityChatPrivateKey, peerRoster, }) {
|
|
34
|
+
function toSpecs(devices) {
|
|
35
|
+
return devices.flatMap(device => {
|
|
36
|
+
// A malformed entry would derive a topic no peer writes to; skip it rather than
|
|
37
|
+
// listening on a dead one.
|
|
38
|
+
if (!isValidDevice(device))
|
|
39
|
+
return [];
|
|
40
|
+
let sharedSecret;
|
|
41
|
+
try {
|
|
42
|
+
// Throws on a small-order peer key (RFC 7748); a malformed roster entry must not
|
|
43
|
+
// take down the whole topic set, so it is skipped instead.
|
|
44
|
+
sharedSecret = x25519.getSharedSecret(ownIdentityChatPrivateKey, device.encryptionPublicKey);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const remoteDevice = { accountId: device.statementAccountId, pin: remotePin };
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
topic: createSessionId(sharedSecret, remoteDevice, localIdentity),
|
|
53
|
+
senderEncryptionPublicKey: device.encryptionPublicKey,
|
|
54
|
+
encryption: createEncryption(sharedSecret),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
let specs = toSpecs(peerRoster.current());
|
|
60
|
+
return {
|
|
61
|
+
current: () => specs,
|
|
62
|
+
subscribe(callback) {
|
|
63
|
+
return peerRoster.subscribe(devices => {
|
|
64
|
+
specs = toSpecs(devices);
|
|
65
|
+
callback(specs);
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the statement body for an outgoing request/response.
|
|
3
|
+
*
|
|
4
|
+
* Two wire formats behind one interface — single-device (`StatementData.request`/
|
|
5
|
+
* `.response`) and multi-device (`.multiRequest`/`.multiResponse`, wrapped per recipient
|
|
6
|
+
* device). Fixed at session construction; the driver never branches on format.
|
|
7
|
+
*
|
|
8
|
+
* The builder is also the session's SIZE ORACLE. The driver builds the body it is about
|
|
9
|
+
* to submit and measures `body.data.length`, so AEAD expansion and per-device envelope
|
|
10
|
+
* fan-out are counted by construction — the two things a pre-encryption byte estimate
|
|
11
|
+
* misses, and the reason a multi-device batch cannot be sized from raw message bytes.
|
|
12
|
+
* On the happy path the measured body is the one submitted, so the check costs nothing.
|
|
13
|
+
*/
|
|
14
|
+
import type { Result } from 'neverthrow';
|
|
15
|
+
import type { SessionId } from '../../model/session.js';
|
|
16
|
+
import type { Encryption } from '../encyption.js';
|
|
17
|
+
import type { ResponseStatus } from '../scale/statementData.js';
|
|
18
|
+
import type { DeviceTarget, Envelope } from './envelope.js';
|
|
19
|
+
export type StatementBody = {
|
|
20
|
+
channel: Uint8Array;
|
|
21
|
+
topics: Uint8Array[];
|
|
22
|
+
data: Uint8Array;
|
|
23
|
+
};
|
|
24
|
+
export type OutgoingBodyBuilder = {
|
|
25
|
+
buildRequest(requestId: string, messages: Uint8Array[]): Result<StatementBody, Error>;
|
|
26
|
+
buildResponse(requestId: string, responseCode: ResponseStatus): Result<StatementBody, Error>;
|
|
27
|
+
};
|
|
28
|
+
export declare function createRequestChannel(sessionId: Uint8Array): Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
|
|
29
|
+
export declare function createResponseChannel(sessionId: Uint8Array): Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
|
|
30
|
+
/**
|
|
31
|
+
* Omit `multiDevice` for the single-device format (`StatementData.request`/`.response`
|
|
32
|
+
* straight under the pairwise encryption). Supply it to wrap the inner `Request`/`Response`
|
|
33
|
+
* for every recipient device first (mds.md §"Sending P2P Messages"); the outer encryption
|
|
34
|
+
* layer is the same either way.
|
|
35
|
+
*
|
|
36
|
+
* `recipients` is a thunk so a peer roster change is picked up on the next submit without
|
|
37
|
+
* rebuilding the session.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createBodyBuilder({ topic, encryption, multiDevice, }: {
|
|
40
|
+
topic: SessionId;
|
|
41
|
+
encryption: Encryption;
|
|
42
|
+
multiDevice?: {
|
|
43
|
+
envelope: Envelope;
|
|
44
|
+
recipients: () => DeviceTarget[];
|
|
45
|
+
};
|
|
46
|
+
}): OutgoingBodyBuilder;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the statement body for an outgoing request/response.
|
|
3
|
+
*
|
|
4
|
+
* Two wire formats behind one interface — single-device (`StatementData.request`/
|
|
5
|
+
* `.response`) and multi-device (`.multiRequest`/`.multiResponse`, wrapped per recipient
|
|
6
|
+
* device). Fixed at session construction; the driver never branches on format.
|
|
7
|
+
*
|
|
8
|
+
* The builder is also the session's SIZE ORACLE. The driver builds the body it is about
|
|
9
|
+
* to submit and measures `body.data.length`, so AEAD expansion and per-device envelope
|
|
10
|
+
* fan-out are counted by construction — the two things a pre-encryption byte estimate
|
|
11
|
+
* misses, and the reason a multi-device batch cannot be sized from raw message bytes.
|
|
12
|
+
* On the happy path the measured body is the one submitted, so the check costs nothing.
|
|
13
|
+
*/
|
|
14
|
+
import { fromThrowable } from 'neverthrow';
|
|
15
|
+
import { khash, stringToBytes } from '../../crypto.js';
|
|
16
|
+
import { toError } from '../../helpers.js';
|
|
17
|
+
import { Request, RequestDeviceInfo, Response, StatementData } from '../scale/statementData.js';
|
|
18
|
+
const REQUEST_LABEL = stringToBytes('request');
|
|
19
|
+
const RESPONSE_LABEL = stringToBytes('response');
|
|
20
|
+
const encodeStatementData = fromThrowable(StatementData.enc, toError);
|
|
21
|
+
const encodeRequest = fromThrowable(Request.enc, toError);
|
|
22
|
+
const encodeResponse = fromThrowable(Response.enc, toError);
|
|
23
|
+
export function createRequestChannel(sessionId) {
|
|
24
|
+
return khash(sessionId, REQUEST_LABEL);
|
|
25
|
+
}
|
|
26
|
+
export function createResponseChannel(sessionId) {
|
|
27
|
+
return khash(sessionId, RESPONSE_LABEL);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Omit `multiDevice` for the single-device format (`StatementData.request`/`.response`
|
|
31
|
+
* straight under the pairwise encryption). Supply it to wrap the inner `Request`/`Response`
|
|
32
|
+
* for every recipient device first (mds.md §"Sending P2P Messages"); the outer encryption
|
|
33
|
+
* layer is the same either way.
|
|
34
|
+
*
|
|
35
|
+
* `recipients` is a thunk so a peer roster change is picked up on the next submit without
|
|
36
|
+
* rebuilding the session.
|
|
37
|
+
*/
|
|
38
|
+
export function createBodyBuilder({ topic, encryption, multiDevice, }) {
|
|
39
|
+
const requestChannel = createRequestChannel(topic);
|
|
40
|
+
const responseChannel = createResponseChannel(topic);
|
|
41
|
+
const seal = (channel, payload) => payload.andThen(encryption.encrypt).map(data => ({ channel, topics: [topic], data }));
|
|
42
|
+
// Wrap an inner Request/Response into its multi-device StatementData variant.
|
|
43
|
+
const wrap = (md, inner, toStatementData) => inner
|
|
44
|
+
.andThen(bytes => md.envelope.wrap(bytes, md.recipients()))
|
|
45
|
+
.andThen(wrapped => encodeStatementData(toStatementData(wrapped.encryptedPayload, wrapped.devicesInfo)));
|
|
46
|
+
return {
|
|
47
|
+
buildRequest(requestId, messages) {
|
|
48
|
+
return seal(requestChannel, multiDevice
|
|
49
|
+
? wrap(multiDevice, encodeRequest({ requestId, data: messages }), (encryptedRequest, devicesInfo) => ({
|
|
50
|
+
tag: 'multiRequest',
|
|
51
|
+
value: { encryptedRequest, devicesInfo },
|
|
52
|
+
}))
|
|
53
|
+
: encodeStatementData({ tag: 'request', value: { requestId, data: messages } }));
|
|
54
|
+
},
|
|
55
|
+
buildResponse(requestId, responseCode) {
|
|
56
|
+
return seal(responseChannel, multiDevice
|
|
57
|
+
? wrap(multiDevice, encodeResponse({ requestId, responseCode }), (encryptedResponse, devicesInfo) => ({
|
|
58
|
+
tag: 'multiResponse',
|
|
59
|
+
value: { encryptedResponse, devicesInfo },
|
|
60
|
+
}))
|
|
61
|
+
: encodeStatementData({ tag: 'response', value: { requestId, responseCode } }));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session driver: turns the collaborators assembled by `createSession` /
|
|
3
|
+
* `createMultiDeviceSession` into a live {@link Session}.
|
|
4
|
+
*
|
|
5
|
+
* It never branches on single- vs multi-device. Wire format is decided by the injected
|
|
6
|
+
* {@link OutgoingBodyBuilder} and {@link StatementDecoder}; which topics to listen on by
|
|
7
|
+
* the injected {@link IncomingTopics}. Everything here is transport policy that both
|
|
8
|
+
* session kinds share: batching, dedup, the outgoing/incoming request state machine,
|
|
9
|
+
* expiry allocation, retries, and subscriber delivery.
|
|
10
|
+
*/
|
|
11
|
+
import type { StatementStoreAdapter } from '../adapter/types.js';
|
|
12
|
+
import type { SessionId } from '../model/session.js';
|
|
13
|
+
import type { ExpiryAllocator } from '../submit/allocator.js';
|
|
14
|
+
import type { StatementDecoder } from './codec/decoder.js';
|
|
15
|
+
import type { DeviceTarget } from './codec/envelope.js';
|
|
16
|
+
import type { IncomingTopics } from './codec/incomingTopics.js';
|
|
17
|
+
import type { OutgoingBodyBuilder } from './codec/outgoingBody.js';
|
|
18
|
+
import type { StatementProver } from './statementProver.js';
|
|
19
|
+
import type { Session } from './types.js';
|
|
20
|
+
/**
|
|
21
|
+
* The Bulletin statement store caps a statement at roughly 500 KiB of total encoded size
|
|
22
|
+
* (proof + channel + topics + expiry + data); 2 KiB leaves margin for the non-data fields.
|
|
23
|
+
* `DataTooLargeError.available` is the chain's authoritative number if this ever drifts.
|
|
24
|
+
*
|
|
25
|
+
* This is deliberately the transport's real capacity rather than an application policy.
|
|
26
|
+
* A too-small budget degrades silently — messages queue instead of batching, with no
|
|
27
|
+
* error — whereas a too-large one fails loudly and recoverably with `DataTooLargeError`.
|
|
28
|
+
* Applications that want a tighter bound (Android's chat uses 100 KiB) should pass their
|
|
29
|
+
* own `maxRequestSize`; base-spec.md leaves the choice to the Application Layer.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_MAX_REQUEST_SIZE: number;
|
|
32
|
+
/**
|
|
33
|
+
* Fixed per-statement wire overhead reserved before sizing the request payload:
|
|
34
|
+
* topic (32) + channel (32) + expiry (8) + proof signature (64) + signer (32).
|
|
35
|
+
* Mirrors the Android/iOS sessions, which size message batches against
|
|
36
|
+
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
37
|
+
*/
|
|
38
|
+
export declare const STATEMENT_OVERHEAD: number;
|
|
39
|
+
/**
|
|
40
|
+
* Collaborators the driver runs on. `createSession` / `createMultiDeviceSession` assemble
|
|
41
|
+
* these; the driver itself never branches on single- vs multi-device.
|
|
42
|
+
*/
|
|
43
|
+
export type SessionCoreParams = {
|
|
44
|
+
statementStore: StatementStoreAdapter;
|
|
45
|
+
prover: StatementProver;
|
|
46
|
+
allocator: ExpiryAllocator;
|
|
47
|
+
maxRequestSize: number;
|
|
48
|
+
/** Wire-format writer, and the session's size oracle. */
|
|
49
|
+
bodyBuilder: OutgoingBodyBuilder;
|
|
50
|
+
/** Topic(s) we listen on, with the keys to read each. */
|
|
51
|
+
incomingTopics: IncomingTopics;
|
|
52
|
+
decoder: StatementDecoder;
|
|
53
|
+
/** Topic our own statements are published on — queried to restore state at init. */
|
|
54
|
+
outgoingTopic: SessionId;
|
|
55
|
+
/** Peer devices, for reading back our own multi-device envelopes. Empty when single-device. */
|
|
56
|
+
peerDevices: () => DeviceTarget[];
|
|
57
|
+
};
|
|
58
|
+
export declare function createSessionCore({ statementStore, prover, allocator, maxRequestSize, bodyBuilder, incomingTopics, decoder, outgoingTopic, peerDevices, }: SessionCoreParams): Session;
|