@novasamatech/statement-store 0.9.0 → 0.9.2
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/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/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/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 +32 -0
- package/dist/session/session.d.ts +15 -5
- package/dist/session/session.js +31 -633
- package/dist/session/session.spec.js +22 -6
- 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,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;
|