@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
package/dist/helpers.d.ts
CHANGED
package/dist/helpers.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,14 @@ export { SessionIdCodec, createSessionId } from './model/session.js';
|
|
|
4
4
|
export type { AccountId, LocalSessionAccount, RemoteSessionAccount, SessionAccount } from './model/sessionAccount.js';
|
|
5
5
|
export { AccountIdCodec, LocalSessionAccountCodec, RemoteSessionAccountCodec, createAccountId, createLocalSessionAccount, createRemoteSessionAccount, } from './model/sessionAccount.js';
|
|
6
6
|
export type { Session } from './session/types.js';
|
|
7
|
+
export type { SessionParams } from './session/session.js';
|
|
7
8
|
export { createSession } from './session/session.js';
|
|
9
|
+
export type { MultiDeviceSessionParams } from './session/multiDeviceSession.js';
|
|
10
|
+
export { createMultiDeviceSession } from './session/multiDeviceSession.js';
|
|
11
|
+
export type { DeviceTarget, Envelope } from './session/codec/envelope.js';
|
|
12
|
+
export { createEnvelope } from './session/codec/envelope.js';
|
|
13
|
+
export type { PeerRoster } from './session/codec/incomingTopics.js';
|
|
14
|
+
export { createRequestChannel, createResponseChannel } from './session/codec/outgoingBody.js';
|
|
8
15
|
export type { ResponseStatus } from './session/scale/statementData.js';
|
|
9
16
|
export { Request, Response, ResponseCode, StatementData } from './session/scale/statementData.js';
|
|
10
17
|
export type { StatementProver } from './session/statementProver.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { SessionIdCodec, createSessionId } from './model/session.js';
|
|
2
2
|
export { AccountIdCodec, LocalSessionAccountCodec, RemoteSessionAccountCodec, createAccountId, createLocalSessionAccount, createRemoteSessionAccount, } from './model/sessionAccount.js';
|
|
3
3
|
export { createSession } from './session/session.js';
|
|
4
|
+
export { createMultiDeviceSession } from './session/multiDeviceSession.js';
|
|
5
|
+
export { createEnvelope } from './session/codec/envelope.js';
|
|
6
|
+
export { createRequestChannel, createResponseChannel } from './session/codec/outgoingBody.js';
|
|
4
7
|
export { Request, Response, ResponseCode, StatementData } from './session/scale/statementData.js';
|
|
5
8
|
export { createSlotAccountProver, createSr25519Prover } from './session/statementProver.js';
|
|
6
9
|
export { createEncryption } from './session/encyption.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statement → {@link TransportEvent} decoding, transparent across all four
|
|
3
|
+
* `StatementData` variants.
|
|
4
|
+
*
|
|
5
|
+
* Every statement goes: verify proof → decrypt outer pairwise layer → decode
|
|
6
|
+
* `StatementData`. Single-device variants (tags 0/1) are used directly; multi-device
|
|
7
|
+
* variants (tags 2/3) are unwrapped through the {@link Envelope} first, then their inner
|
|
8
|
+
* `Request`/`Response` is decoded.
|
|
9
|
+
*
|
|
10
|
+
* Two directions, because our own envelopes carry no entry addressed to us:
|
|
11
|
+
* - {@link StatementDecoder.decodePeer} — a peer's statement, decrypted with the topic's
|
|
12
|
+
* encryption and unwrapped against the sending device's encryption pubkey.
|
|
13
|
+
* - {@link StatementDecoder.decodeOwn} — one of our own statements read back from the
|
|
14
|
+
* store during initialization, decrypted with our outgoing encryption and unwrapped
|
|
15
|
+
* against a recipient device we wrapped it for.
|
|
16
|
+
*
|
|
17
|
+
* A statement whose outer layer decrypts but whose payload does not decode yields
|
|
18
|
+
* `undecodable` with a best-effort `requestId`, so the caller can still NACK the sender
|
|
19
|
+
* instead of leaving it waiting.
|
|
20
|
+
*/
|
|
21
|
+
import type { Statement } from '@novasamatech/sdk-statement';
|
|
22
|
+
import type { ResultAsync } from 'neverthrow';
|
|
23
|
+
import type { Encryption } from '../encyption.js';
|
|
24
|
+
import type { ResponseStatus } from '../scale/statementData.js';
|
|
25
|
+
import type { StatementProver } from '../statementProver.js';
|
|
26
|
+
import type { DeviceTarget, Envelope } from './envelope.js';
|
|
27
|
+
export type TransportEvent = {
|
|
28
|
+
tag: 'request';
|
|
29
|
+
requestId: string;
|
|
30
|
+
messages: Uint8Array[];
|
|
31
|
+
expiry: bigint | undefined;
|
|
32
|
+
} | {
|
|
33
|
+
tag: 'response';
|
|
34
|
+
requestId: string;
|
|
35
|
+
responseCode: ResponseStatus;
|
|
36
|
+
expiry: bigint | undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Outer decryption succeeded (so the sender is genuine) but the payload did not decode.
|
|
40
|
+
* `requestId` is recovered when possible so the sender can be NACKed; `null` otherwise.
|
|
41
|
+
*/
|
|
42
|
+
| {
|
|
43
|
+
tag: 'undecodable';
|
|
44
|
+
requestId: string | null;
|
|
45
|
+
};
|
|
46
|
+
/** An event carrying an actual payload — everything except {@link TransportEvent} `undecodable`. */
|
|
47
|
+
export type ReadableEvent = Extract<TransportEvent, {
|
|
48
|
+
tag: 'request' | 'response';
|
|
49
|
+
}>;
|
|
50
|
+
/** Everything needed to read statements arriving on one incoming topic. */
|
|
51
|
+
export type IncomingTopicSpec = {
|
|
52
|
+
topic: Uint8Array;
|
|
53
|
+
/** Encryption pubkey of the device publishing here — the envelope unwrap counterparty. */
|
|
54
|
+
senderEncryptionPublicKey: Uint8Array;
|
|
55
|
+
/** Outer pairwise encryption for this topic. */
|
|
56
|
+
encryption: Encryption;
|
|
57
|
+
};
|
|
58
|
+
export type StatementDecoder = {
|
|
59
|
+
decodePeer(statement: Statement, spec: IncomingTopicSpec): ResultAsync<TransportEvent, Error>;
|
|
60
|
+
decodeOwn(statement: Statement, peerDevices: DeviceTarget[]): ResultAsync<TransportEvent, Error>;
|
|
61
|
+
};
|
|
62
|
+
export declare function createStatementDecoder({ prover, envelope, ownEncryption, }: {
|
|
63
|
+
prover: StatementProver;
|
|
64
|
+
/** Omit for a single-device session: multi-device variants then decode as `undecodable`. */
|
|
65
|
+
envelope?: Envelope;
|
|
66
|
+
/** Outer encryption of our OWN outgoing statements — used only by {@link StatementDecoder.decodeOwn}. */
|
|
67
|
+
ownEncryption: Encryption;
|
|
68
|
+
}): StatementDecoder;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statement → {@link TransportEvent} decoding, transparent across all four
|
|
3
|
+
* `StatementData` variants.
|
|
4
|
+
*
|
|
5
|
+
* Every statement goes: verify proof → decrypt outer pairwise layer → decode
|
|
6
|
+
* `StatementData`. Single-device variants (tags 0/1) are used directly; multi-device
|
|
7
|
+
* variants (tags 2/3) are unwrapped through the {@link Envelope} first, then their inner
|
|
8
|
+
* `Request`/`Response` is decoded.
|
|
9
|
+
*
|
|
10
|
+
* Two directions, because our own envelopes carry no entry addressed to us:
|
|
11
|
+
* - {@link StatementDecoder.decodePeer} — a peer's statement, decrypted with the topic's
|
|
12
|
+
* encryption and unwrapped against the sending device's encryption pubkey.
|
|
13
|
+
* - {@link StatementDecoder.decodeOwn} — one of our own statements read back from the
|
|
14
|
+
* store during initialization, decrypted with our outgoing encryption and unwrapped
|
|
15
|
+
* against a recipient device we wrapped it for.
|
|
16
|
+
*
|
|
17
|
+
* A statement whose outer layer decrypts but whose payload does not decode yields
|
|
18
|
+
* `undecodable` with a best-effort `requestId`, so the caller can still NACK the sender
|
|
19
|
+
* instead of leaving it waiting.
|
|
20
|
+
*/
|
|
21
|
+
import { err, errAsync, fromThrowable, ok } from 'neverthrow';
|
|
22
|
+
import { Struct, str } from 'scale-ts';
|
|
23
|
+
import { toError } from '../../helpers.js';
|
|
24
|
+
import { Request, RequestDeviceInfo, Response, StatementData } from '../scale/statementData.js';
|
|
25
|
+
const decodeStatementData = fromThrowable(StatementData.dec, toError);
|
|
26
|
+
const decodeRequest = fromThrowable(Request.dec, toError);
|
|
27
|
+
const decodeResponse = fromThrowable(Response.dec, toError);
|
|
28
|
+
// Best-effort recovery of the requestId from a decrypted-but-undecodable payload. The
|
|
29
|
+
// requestId is the first field after the enum tag, so it usually survives a corrupt body.
|
|
30
|
+
// Only requests (tag 0) carry an id worth answering; anything else returns null.
|
|
31
|
+
const RequestIdPrefix = Struct({ requestId: str });
|
|
32
|
+
const decodeRequestIdPrefix = fromThrowable(
|
|
33
|
+
// slice (a copy), not subarray: scale-ts decodes from the backing buffer start and
|
|
34
|
+
// ignores a view's byteOffset, so a subarray would be read from the wrong position.
|
|
35
|
+
(decrypted) => RequestIdPrefix.dec(decrypted.slice(1)).requestId, () => null);
|
|
36
|
+
function recoverRequestId(decrypted) {
|
|
37
|
+
if (decrypted.length < 1 || decrypted[0] !== 0)
|
|
38
|
+
return null;
|
|
39
|
+
return decodeRequestIdPrefix(decrypted).unwrapOr(null);
|
|
40
|
+
}
|
|
41
|
+
function toRequestEvent(value, expiry) {
|
|
42
|
+
return { tag: 'request', requestId: value.requestId, messages: value.data, expiry };
|
|
43
|
+
}
|
|
44
|
+
function toResponseEvent(value, expiry) {
|
|
45
|
+
return { tag: 'response', requestId: value.requestId, responseCode: value.responseCode, expiry };
|
|
46
|
+
}
|
|
47
|
+
// An envelope we cannot open carries no recoverable requestId — the id lives inside it.
|
|
48
|
+
const UNOPENABLE = { tag: 'undecodable', requestId: null };
|
|
49
|
+
export function createStatementDecoder({ prover, envelope, ownEncryption, }) {
|
|
50
|
+
function decode(statement, encryption, unwrap) {
|
|
51
|
+
const data = statement.data;
|
|
52
|
+
if (!data)
|
|
53
|
+
return errAsync(new Error('decoder: statement carries no data'));
|
|
54
|
+
return prover
|
|
55
|
+
.verifyMessageProof(statement)
|
|
56
|
+
.andThen(verified => (verified ? ok() : err(new Error('decoder: invalid statement proof'))))
|
|
57
|
+
.andThen(() => encryption.decrypt(data))
|
|
58
|
+
.map(decrypted => toEvent(decrypted, statement.expiry, unwrap));
|
|
59
|
+
}
|
|
60
|
+
function toEvent(decrypted, expiry, unwrap) {
|
|
61
|
+
const decoded = decodeStatementData(decrypted);
|
|
62
|
+
if (decoded.isErr())
|
|
63
|
+
return { tag: 'undecodable', requestId: recoverRequestId(decrypted) };
|
|
64
|
+
const statementData = decoded.value;
|
|
65
|
+
switch (statementData.tag) {
|
|
66
|
+
case 'request':
|
|
67
|
+
return toRequestEvent(statementData.value, expiry);
|
|
68
|
+
case 'response':
|
|
69
|
+
return toResponseEvent(statementData.value, expiry);
|
|
70
|
+
case 'multiRequest':
|
|
71
|
+
if (!unwrap)
|
|
72
|
+
return UNOPENABLE;
|
|
73
|
+
return unwrap(statementData.value.encryptedRequest, statementData.value.devicesInfo)
|
|
74
|
+
.andThen(decodeRequest)
|
|
75
|
+
.map(value => toRequestEvent(value, expiry))
|
|
76
|
+
.unwrapOr(UNOPENABLE);
|
|
77
|
+
case 'multiResponse':
|
|
78
|
+
if (!unwrap)
|
|
79
|
+
return UNOPENABLE;
|
|
80
|
+
return unwrap(statementData.value.encryptedResponse, statementData.value.devicesInfo)
|
|
81
|
+
.andThen(decodeResponse)
|
|
82
|
+
.map(value => toResponseEvent(value, expiry))
|
|
83
|
+
.unwrapOr(UNOPENABLE);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
decodePeer(statement, spec) {
|
|
88
|
+
return decode(statement, spec.encryption, envelope
|
|
89
|
+
? (payload, devicesInfo) => envelope.unwrapForOwnDevice(payload, devicesInfo, spec.senderEncryptionPublicKey)
|
|
90
|
+
: null);
|
|
91
|
+
},
|
|
92
|
+
decodeOwn(statement, peerDevices) {
|
|
93
|
+
return decode(statement, ownEncryption, envelope ? (payload, devicesInfo) => envelope.unwrapOwn(payload, devicesInfo, peerDevices) : null);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
2
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
3
|
+
import { ok, okAsync } from 'neverthrow';
|
|
4
|
+
import { mergeUint8 } from 'polkadot-api/utils';
|
|
5
|
+
import { compact, str } from 'scale-ts';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { Request, Response, StatementData } from '../scale/statementData.js';
|
|
8
|
+
import { createStatementDecoder } from './decoder.js';
|
|
9
|
+
import { createEnvelope } from './envelope.js';
|
|
10
|
+
const acceptingProver = {
|
|
11
|
+
generateMessageProof: statement => okAsync({ ...statement, proof: undefined }),
|
|
12
|
+
verifyMessageProof: () => okAsync(true),
|
|
13
|
+
};
|
|
14
|
+
const rejectingProver = { ...acceptingProver, verifyMessageProof: () => okAsync(false) };
|
|
15
|
+
/** Identity encryption keeps the tests focused on decode paths, not on AEAD. */
|
|
16
|
+
function passthroughEncryption() {
|
|
17
|
+
return { encrypt: data => ok(data), decrypt: data => ok(data) };
|
|
18
|
+
}
|
|
19
|
+
function createDevice() {
|
|
20
|
+
const encryptionPrivateKey = x25519.utils.randomSecretKey();
|
|
21
|
+
return {
|
|
22
|
+
statementAccountId: randomBytes(32),
|
|
23
|
+
encryptionPublicKey: x25519.getPublicKey(encryptionPrivateKey),
|
|
24
|
+
encryptionPrivateKey,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function envelopeFor(device) {
|
|
28
|
+
return createEnvelope({
|
|
29
|
+
ownStatementAccountId: device.statementAccountId,
|
|
30
|
+
ownEncryptionPrivateKey: device.encryptionPrivateKey,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function statementWith(data) {
|
|
34
|
+
return { expiry: 42n, data, topics: [], channel: `0x${'00'.repeat(32)}` };
|
|
35
|
+
}
|
|
36
|
+
function specFor(senderEncryptionPublicKey) {
|
|
37
|
+
return { topic: new Uint8Array(32), senderEncryptionPublicKey, encryption: passthroughEncryption() };
|
|
38
|
+
}
|
|
39
|
+
const MESSAGES = [new TextEncoder().encode('hello'), new TextEncoder().encode('world')];
|
|
40
|
+
function singleDeviceDecoder(prover = acceptingProver) {
|
|
41
|
+
// No envelope — a single-device session cannot open multi-device variants.
|
|
42
|
+
return createStatementDecoder({ prover, ownEncryption: passthroughEncryption() });
|
|
43
|
+
}
|
|
44
|
+
describe('statement decoder', () => {
|
|
45
|
+
it('decodes a single-device request', async () => {
|
|
46
|
+
const data = StatementData.enc({ tag: 'request', value: { requestId: 'r1', data: MESSAGES } });
|
|
47
|
+
const event = (await singleDeviceDecoder().decodePeer(statementWith(data), specFor(randomBytes(32))))._unsafeUnwrap();
|
|
48
|
+
expect(event).toEqual({ tag: 'request', requestId: 'r1', messages: MESSAGES, expiry: 42n });
|
|
49
|
+
});
|
|
50
|
+
it('decodes a single-device response', async () => {
|
|
51
|
+
const data = StatementData.enc({ tag: 'response', value: { requestId: 'r1', responseCode: 'success' } });
|
|
52
|
+
const event = (await singleDeviceDecoder().decodePeer(statementWith(data), specFor(randomBytes(32))))._unsafeUnwrap();
|
|
53
|
+
expect(event).toEqual({ tag: 'response', requestId: 'r1', responseCode: 'success', expiry: 42n });
|
|
54
|
+
});
|
|
55
|
+
it('decodes a peer multiRequest addressed to this device', async () => {
|
|
56
|
+
const sender = createDevice();
|
|
57
|
+
const own = createDevice();
|
|
58
|
+
const inner = Request.enc({ requestId: 'r2', data: MESSAGES });
|
|
59
|
+
const wrapped = envelopeFor(sender).wrap(inner, [own])._unsafeUnwrap();
|
|
60
|
+
const data = StatementData.enc({
|
|
61
|
+
tag: 'multiRequest',
|
|
62
|
+
value: { encryptedRequest: wrapped.encryptedPayload, devicesInfo: wrapped.devicesInfo },
|
|
63
|
+
});
|
|
64
|
+
const decoder = createStatementDecoder({
|
|
65
|
+
prover: acceptingProver,
|
|
66
|
+
envelope: envelopeFor(own),
|
|
67
|
+
ownEncryption: passthroughEncryption(),
|
|
68
|
+
});
|
|
69
|
+
const event = (await decoder.decodePeer(statementWith(data), specFor(sender.encryptionPublicKey)))._unsafeUnwrap();
|
|
70
|
+
expect(event).toEqual({ tag: 'request', requestId: 'r2', messages: MESSAGES, expiry: 42n });
|
|
71
|
+
});
|
|
72
|
+
it('decodes a peer multiResponse addressed to this device', async () => {
|
|
73
|
+
const sender = createDevice();
|
|
74
|
+
const own = createDevice();
|
|
75
|
+
const inner = Response.enc({ requestId: 'r3', responseCode: 'success' });
|
|
76
|
+
const wrapped = envelopeFor(sender).wrap(inner, [own])._unsafeUnwrap();
|
|
77
|
+
const data = StatementData.enc({
|
|
78
|
+
tag: 'multiResponse',
|
|
79
|
+
value: { encryptedResponse: wrapped.encryptedPayload, devicesInfo: wrapped.devicesInfo },
|
|
80
|
+
});
|
|
81
|
+
const decoder = createStatementDecoder({
|
|
82
|
+
prover: acceptingProver,
|
|
83
|
+
envelope: envelopeFor(own),
|
|
84
|
+
ownEncryption: passthroughEncryption(),
|
|
85
|
+
});
|
|
86
|
+
const event = (await decoder.decodePeer(statementWith(data), specFor(sender.encryptionPublicKey)))._unsafeUnwrap();
|
|
87
|
+
expect(event).toEqual({ tag: 'response', requestId: 'r3', responseCode: 'success', expiry: 42n });
|
|
88
|
+
});
|
|
89
|
+
// The initialization-phase read-back that replaces a client-side outbox: our own
|
|
90
|
+
// envelope has no entry for us, so it is opened via a recipient device instead.
|
|
91
|
+
it('decodes our OWN multiRequest read back from the store', async () => {
|
|
92
|
+
const own = createDevice();
|
|
93
|
+
const peerA = createDevice();
|
|
94
|
+
const peerB = createDevice();
|
|
95
|
+
const peers = [peerA, peerB];
|
|
96
|
+
const inner = Request.enc({ requestId: 'own-1', data: MESSAGES });
|
|
97
|
+
const wrapped = envelopeFor(own).wrap(inner, peers)._unsafeUnwrap();
|
|
98
|
+
const data = StatementData.enc({
|
|
99
|
+
tag: 'multiRequest',
|
|
100
|
+
value: { encryptedRequest: wrapped.encryptedPayload, devicesInfo: wrapped.devicesInfo },
|
|
101
|
+
});
|
|
102
|
+
const decoder = createStatementDecoder({
|
|
103
|
+
prover: acceptingProver,
|
|
104
|
+
envelope: envelopeFor(own),
|
|
105
|
+
ownEncryption: passthroughEncryption(),
|
|
106
|
+
});
|
|
107
|
+
const event = (await decoder.decodeOwn(statementWith(data), peers))._unsafeUnwrap();
|
|
108
|
+
expect(event).toEqual({ tag: 'request', requestId: 'own-1', messages: MESSAGES, expiry: 42n });
|
|
109
|
+
});
|
|
110
|
+
it('reports an unopenable envelope as undecodable with no requestId', async () => {
|
|
111
|
+
const sender = createDevice();
|
|
112
|
+
const intended = createDevice();
|
|
113
|
+
const outsider = createDevice();
|
|
114
|
+
const inner = Request.enc({ requestId: 'r4', data: MESSAGES });
|
|
115
|
+
const wrapped = envelopeFor(sender).wrap(inner, [intended])._unsafeUnwrap();
|
|
116
|
+
const data = StatementData.enc({
|
|
117
|
+
tag: 'multiRequest',
|
|
118
|
+
value: { encryptedRequest: wrapped.encryptedPayload, devicesInfo: wrapped.devicesInfo },
|
|
119
|
+
});
|
|
120
|
+
const decoder = createStatementDecoder({
|
|
121
|
+
prover: acceptingProver,
|
|
122
|
+
envelope: envelopeFor(outsider),
|
|
123
|
+
ownEncryption: passthroughEncryption(),
|
|
124
|
+
});
|
|
125
|
+
const event = (await decoder.decodePeer(statementWith(data), specFor(sender.encryptionPublicKey)))._unsafeUnwrap();
|
|
126
|
+
expect(event).toEqual({ tag: 'undecodable', requestId: null });
|
|
127
|
+
});
|
|
128
|
+
// Decrypted but malformed: the requestId survives right after the enum tag, so the
|
|
129
|
+
// sender can still be NACKed rather than left waiting.
|
|
130
|
+
it('recovers the requestId from a decrypted-but-malformed request', async () => {
|
|
131
|
+
// tag(request) : requestId : a message-vector length that no bytes back up.
|
|
132
|
+
const malformed = mergeUint8([new Uint8Array([0]), str.enc('recover-me'), compact.enc(255)]);
|
|
133
|
+
const event = (await singleDeviceDecoder().decodePeer(statementWith(malformed), specFor(randomBytes(32))))._unsafeUnwrap();
|
|
134
|
+
expect(event).toEqual({ tag: 'undecodable', requestId: 'recover-me' });
|
|
135
|
+
});
|
|
136
|
+
it('reports garbage with no recoverable requestId as undecodable', async () => {
|
|
137
|
+
const event = (await singleDeviceDecoder().decodePeer(statementWith(new Uint8Array([9, 9, 9])), specFor(randomBytes(32))))._unsafeUnwrap();
|
|
138
|
+
expect(event).toEqual({ tag: 'undecodable', requestId: null });
|
|
139
|
+
});
|
|
140
|
+
it('fails a statement whose proof does not verify', async () => {
|
|
141
|
+
const data = StatementData.enc({ tag: 'request', value: { requestId: 'r5', data: MESSAGES } });
|
|
142
|
+
const result = await singleDeviceDecoder(rejectingProver).decodePeer(statementWith(data), specFor(randomBytes(32)));
|
|
143
|
+
expect(result.isErr()).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
it('fails a statement carrying no data', async () => {
|
|
146
|
+
const result = await singleDeviceDecoder().decodePeer({ expiry: 1n, topics: [] }, specFor(randomBytes(32)));
|
|
147
|
+
expect(result.isErr()).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
it('a single-device session cannot open multi-device variants', async () => {
|
|
150
|
+
const sender = createDevice();
|
|
151
|
+
const own = createDevice();
|
|
152
|
+
const inner = Request.enc({ requestId: 'r6', data: MESSAGES });
|
|
153
|
+
const wrapped = envelopeFor(sender).wrap(inner, [own])._unsafeUnwrap();
|
|
154
|
+
const data = StatementData.enc({
|
|
155
|
+
tag: 'multiRequest',
|
|
156
|
+
value: { encryptedRequest: wrapped.encryptedPayload, devicesInfo: wrapped.devicesInfo },
|
|
157
|
+
});
|
|
158
|
+
const event = (await singleDeviceDecoder().decodePeer(statementWith(data), specFor(sender.encryptionPublicKey)))._unsafeUnwrap();
|
|
159
|
+
expect(event).toEqual({ tag: 'undecodable', requestId: null });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-device statement envelope (mds.md §"Sending P2P Messages").
|
|
3
|
+
*
|
|
4
|
+
* A fresh 32-byte one-shot key encrypts the inner `Request`/`Response`; that key is then
|
|
5
|
+
* wrapped once per recipient device via X25519 key agreement between the sender's device
|
|
6
|
+
* encryption key and the recipient device's encryption public key:
|
|
7
|
+
*
|
|
8
|
+
* encryptedPayload = aead(oneShotKey, inner) // key used RAW, no KDF
|
|
9
|
+
* devicesInfo[i] = { statementAccountId, encryptedKey }
|
|
10
|
+
* encryptedKey = encryption(x25519(ownEncPriv, deviceEncPub)).encrypt(oneShotKey)
|
|
11
|
+
*
|
|
12
|
+
* The one-shot key is already uniformly random, so it is used directly as the AEAD key —
|
|
13
|
+
* unlike {@link createEncryption}, which HKDFs its input because that input is a raw ECDH
|
|
14
|
+
* shared secret. This split matches Android (`MultiDeviceEnvelopeEncryption`) byte for byte.
|
|
15
|
+
*
|
|
16
|
+
* Three read paths:
|
|
17
|
+
* - {@link Envelope.unwrapForOwnDevice} — a peer's envelope addressed to us.
|
|
18
|
+
* - {@link Envelope.unwrapOwn} — OUR OWN envelope, read back from the store. The wrap
|
|
19
|
+
* secret is symmetric, so we re-derive it against any recipient device we wrapped for.
|
|
20
|
+
* This is what lets the statement store hold the outgoing-request state through a
|
|
21
|
+
* restart (base-spec.md §"Session Initialization Phase") instead of a client-side outbox.
|
|
22
|
+
* - single-device sessions pass no envelope at all; tags 2/3 then decode as `undecodable`.
|
|
23
|
+
*/
|
|
24
|
+
import { Result } from 'neverthrow';
|
|
25
|
+
import type { CodecType } from 'scale-ts';
|
|
26
|
+
import type { RequestDeviceInfo } from '../scale/statementData.js';
|
|
27
|
+
export type DeviceTarget = {
|
|
28
|
+
/** 32-byte sr25519 statement account id — the device's identifier on the wire. */
|
|
29
|
+
statementAccountId: Uint8Array;
|
|
30
|
+
/** 32-byte X25519 device encryption public key. */
|
|
31
|
+
encryptionPublicKey: Uint8Array;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Both fields are fixed-width on the wire, and `Bytes(32)` zero-pads anything shorter — a
|
|
35
|
+
* malformed roster entry would otherwise yield a valid-looking statement addressed to the
|
|
36
|
+
* wrong device, or a topic no peer ever writes to, with no error anywhere.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isValidDevice(device: DeviceTarget): boolean;
|
|
39
|
+
type DeviceEntry = CodecType<typeof RequestDeviceInfo>;
|
|
40
|
+
type WrappedEnvelope = {
|
|
41
|
+
encryptedPayload: Uint8Array;
|
|
42
|
+
devicesInfo: DeviceEntry[];
|
|
43
|
+
};
|
|
44
|
+
export type Envelope = {
|
|
45
|
+
wrap(plaintext: Uint8Array, recipients: DeviceTarget[]): Result<WrappedEnvelope, Error>;
|
|
46
|
+
/** Decrypt an envelope a peer addressed to this device. */
|
|
47
|
+
unwrapForOwnDevice(encryptedPayload: Uint8Array, devicesInfo: DeviceEntry[], senderEncryptionPublicKey: Uint8Array): Result<Uint8Array, Error>;
|
|
48
|
+
/** Decrypt an envelope WE produced, using any recipient device we wrapped it for. */
|
|
49
|
+
unwrapOwn(encryptedPayload: Uint8Array, devicesInfo: DeviceEntry[], peerDevices: DeviceTarget[]): Result<Uint8Array, Error>;
|
|
50
|
+
};
|
|
51
|
+
export declare function createEnvelope({ ownStatementAccountId, ownEncryptionPrivateKey, }: {
|
|
52
|
+
ownStatementAccountId: Uint8Array;
|
|
53
|
+
ownEncryptionPrivateKey: Uint8Array;
|
|
54
|
+
}): Envelope;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-device statement envelope (mds.md §"Sending P2P Messages").
|
|
3
|
+
*
|
|
4
|
+
* A fresh 32-byte one-shot key encrypts the inner `Request`/`Response`; that key is then
|
|
5
|
+
* wrapped once per recipient device via X25519 key agreement between the sender's device
|
|
6
|
+
* encryption key and the recipient device's encryption public key:
|
|
7
|
+
*
|
|
8
|
+
* encryptedPayload = aead(oneShotKey, inner) // key used RAW, no KDF
|
|
9
|
+
* devicesInfo[i] = { statementAccountId, encryptedKey }
|
|
10
|
+
* encryptedKey = encryption(x25519(ownEncPriv, deviceEncPub)).encrypt(oneShotKey)
|
|
11
|
+
*
|
|
12
|
+
* The one-shot key is already uniformly random, so it is used directly as the AEAD key —
|
|
13
|
+
* unlike {@link createEncryption}, which HKDFs its input because that input is a raw ECDH
|
|
14
|
+
* shared secret. This split matches Android (`MultiDeviceEnvelopeEncryption`) byte for byte.
|
|
15
|
+
*
|
|
16
|
+
* Three read paths:
|
|
17
|
+
* - {@link Envelope.unwrapForOwnDevice} — a peer's envelope addressed to us.
|
|
18
|
+
* - {@link Envelope.unwrapOwn} — OUR OWN envelope, read back from the store. The wrap
|
|
19
|
+
* secret is symmetric, so we re-derive it against any recipient device we wrapped for.
|
|
20
|
+
* This is what lets the statement store hold the outgoing-request state through a
|
|
21
|
+
* restart (base-spec.md §"Session Initialization Phase") instead of a client-side outbox.
|
|
22
|
+
* - single-device sessions pass no envelope at all; tags 2/3 then decode as `undecodable`.
|
|
23
|
+
*/
|
|
24
|
+
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
25
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
26
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
27
|
+
import { Result, err, fromThrowable } from 'neverthrow';
|
|
28
|
+
import { mergeUint8 } from 'polkadot-api/utils';
|
|
29
|
+
import { toError } from '../../helpers.js';
|
|
30
|
+
import { createEncryption } from '../encyption.js';
|
|
31
|
+
/** One-shot symmetric key length. Mirrors Android's `MessageEncryption` (32 bytes). */
|
|
32
|
+
const ONE_SHOT_KEY_BYTES = 32;
|
|
33
|
+
/** AEAD nonce length. Same 12 bytes for AES-GCM and ChaCha20-Poly1305. */
|
|
34
|
+
const AEAD_NONCE_BYTES = 12;
|
|
35
|
+
const ACCOUNT_ID_BYTES = 32;
|
|
36
|
+
const PUBLIC_KEY_BYTES = 32;
|
|
37
|
+
/**
|
|
38
|
+
* Both fields are fixed-width on the wire, and `Bytes(32)` zero-pads anything shorter — a
|
|
39
|
+
* malformed roster entry would otherwise yield a valid-looking statement addressed to the
|
|
40
|
+
* wrong device, or a topic no peer ever writes to, with no error anywhere.
|
|
41
|
+
*/
|
|
42
|
+
export function isValidDevice(device) {
|
|
43
|
+
return (device.statementAccountId.length === ACCOUNT_ID_BYTES && device.encryptionPublicKey.length === PUBLIC_KEY_BYTES);
|
|
44
|
+
}
|
|
45
|
+
function bytesEqual(a, b) {
|
|
46
|
+
if (a.length !== b.length)
|
|
47
|
+
return false;
|
|
48
|
+
for (let i = 0; i < a.length; i++) {
|
|
49
|
+
if (a[i] !== b[i])
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
// The one-shot key is uniformly random already, so it keys the AEAD directly.
|
|
55
|
+
const aeadEncrypt = fromThrowable((key, plaintext) => {
|
|
56
|
+
const nonce = randomBytes(AEAD_NONCE_BYTES);
|
|
57
|
+
return mergeUint8([nonce, chacha20poly1305(key, nonce).encrypt(plaintext)]);
|
|
58
|
+
}, toError);
|
|
59
|
+
const aeadDecrypt = fromThrowable((key, encrypted) => {
|
|
60
|
+
const nonce = encrypted.slice(0, AEAD_NONCE_BYTES);
|
|
61
|
+
const cipherText = encrypted.slice(AEAD_NONCE_BYTES);
|
|
62
|
+
return chacha20poly1305(key, nonce).decrypt(cipherText);
|
|
63
|
+
}, toError);
|
|
64
|
+
/**
|
|
65
|
+
* `@noble` aborts on an all-zero (small-order) X25519 result per RFC 7748, so a hostile
|
|
66
|
+
* device key fails loudly here rather than yielding a predictable key (RFC-0004 §1).
|
|
67
|
+
*/
|
|
68
|
+
const deviceEncryption = fromThrowable((ownEncryptionPrivateKey, peerEncryptionPublicKey) => createEncryption(x25519.getSharedSecret(ownEncryptionPrivateKey, peerEncryptionPublicKey)), toError);
|
|
69
|
+
export function createEnvelope({ ownStatementAccountId, ownEncryptionPrivateKey, }) {
|
|
70
|
+
// Unwrapping a key against a given peer device pubkey — the same derivation the wrap
|
|
71
|
+
// side uses, which is why `unwrapOwn` works at all.
|
|
72
|
+
function unwrapKeyAgainst(peerEncryptionPublicKey, encryptedKey) {
|
|
73
|
+
return deviceEncryption(ownEncryptionPrivateKey, peerEncryptionPublicKey).andThen(encryption => encryption.decrypt(encryptedKey));
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
wrap(plaintext, recipients) {
|
|
77
|
+
if (recipients.length === 0) {
|
|
78
|
+
return err(new Error('envelope: cannot wrap without recipient devices'));
|
|
79
|
+
}
|
|
80
|
+
const malformed = recipients.find(recipient => !isValidDevice(recipient));
|
|
81
|
+
if (malformed) {
|
|
82
|
+
return err(new Error(`envelope: recipient device is malformed (statementAccountId ${malformed.statementAccountId.length.toString()} bytes, encryptionPublicKey ${malformed.encryptionPublicKey.length.toString()} bytes; both must be 32)`));
|
|
83
|
+
}
|
|
84
|
+
const oneShotKey = randomBytes(ONE_SHOT_KEY_BYTES);
|
|
85
|
+
return Result.combine(recipients.map(recipient => deviceEncryption(ownEncryptionPrivateKey, recipient.encryptionPublicKey)
|
|
86
|
+
.andThen(encryption => encryption.encrypt(oneShotKey))
|
|
87
|
+
.map(encryptedKey => ({
|
|
88
|
+
statementAccountId: recipient.statementAccountId,
|
|
89
|
+
encryptedKey,
|
|
90
|
+
})))).andThen(devicesInfo => aeadEncrypt(oneShotKey, plaintext).map(encryptedPayload => ({ encryptedPayload, devicesInfo })));
|
|
91
|
+
},
|
|
92
|
+
unwrapForOwnDevice(encryptedPayload, devicesInfo, senderEncryptionPublicKey) {
|
|
93
|
+
const ownEntry = devicesInfo.find(entry => bytesEqual(entry.statementAccountId, ownStatementAccountId));
|
|
94
|
+
if (!ownEntry) {
|
|
95
|
+
return err(new Error('envelope: no entry addressed to this device'));
|
|
96
|
+
}
|
|
97
|
+
return unwrapKeyAgainst(senderEncryptionPublicKey, ownEntry.encryptedKey).andThen(oneShotKey => aeadDecrypt(oneShotKey, encryptedPayload));
|
|
98
|
+
},
|
|
99
|
+
unwrapOwn(encryptedPayload, devicesInfo, peerDevices) {
|
|
100
|
+
// Any recipient entry works: we wrapped every one of them with our own private key,
|
|
101
|
+
// so re-deriving x25519(ownPriv, thatDevicePub) reproduces the wrap secret exactly.
|
|
102
|
+
for (const device of peerDevices) {
|
|
103
|
+
const entry = devicesInfo.find(candidate => bytesEqual(candidate.statementAccountId, device.statementAccountId));
|
|
104
|
+
if (!entry)
|
|
105
|
+
continue;
|
|
106
|
+
const unwrapped = unwrapKeyAgainst(device.encryptionPublicKey, entry.encryptedKey).andThen(oneShotKey => aeadDecrypt(oneShotKey, encryptedPayload));
|
|
107
|
+
// A stale roster entry can fail to unwrap while a newer one still succeeds, so
|
|
108
|
+
// keep trying the remaining devices rather than failing on the first mismatch.
|
|
109
|
+
if (unwrapped.isOk())
|
|
110
|
+
return unwrapped;
|
|
111
|
+
}
|
|
112
|
+
return err(new Error('envelope: no known peer device entry to unwrap own payload'));
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|