@novasamatech/host-papp 0.8.11 → 0.9.0
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/README.md +84 -69
- package/dist/crypto.js +8 -11
- package/dist/crypto.spec.d.ts +1 -0
- package/dist/crypto.spec.js +31 -0
- package/dist/helpers/createAsyncTaskPool.spec.js +11 -11
- package/dist/index.d.ts +3 -0
- package/dist/papp.js +3 -1
- package/dist/sso/allowance/repository.js +2 -1
- package/dist/sso/auth/scale/handshakeV2.d.ts +5 -5
- package/dist/sso/auth/scale/handshakeV2.js +11 -10
- package/dist/sso/auth/v2/envelope.d.ts +6 -6
- package/dist/sso/auth/v2/envelope.js +9 -9
- package/dist/sso/auth/v2/state.d.ts +2 -2
- package/dist/sso/auth/v2/topic.d.ts +1 -1
- package/dist/sso/auth/v2/topic.js +1 -1
- package/dist/sso/deviceIdentityStore.js +2 -1
- package/dist/sso/sessionManager/impl.d.ts +3 -1
- package/dist/sso/sessionManager/impl.js +6 -4
- package/dist/sso/sessionManager/scale/createTransaction.d.ts +9 -3
- package/dist/sso/sessionManager/scale/createTransaction.js +2 -2
- package/dist/sso/sessionManager/scale/productSubtree.d.ts +12 -0
- package/dist/sso/sessionManager/scale/productSubtree.js +22 -0
- package/dist/sso/sessionManager/scale/remoteMessage.d.ts +100 -24
- package/dist/sso/sessionManager/scale/remoteMessage.js +11 -1
- package/dist/sso/sessionManager/scale/resourceAllocation.d.ts +14 -5
- package/dist/sso/sessionManager/scale/resourceAllocation.js +8 -3
- package/dist/sso/sessionManager/scale/ringVrf.d.ts +18 -29
- package/dist/sso/sessionManager/scale/ringVrf.js +5 -13
- package/dist/sso/sessionManager/scale/signVrf.d.ts +62 -0
- package/dist/sso/sessionManager/scale/signVrf.js +18 -0
- package/dist/sso/sessionManager/scale/signing.d.ts +39 -15
- package/dist/sso/sessionManager/scale/signing.js +2 -2
- package/dist/sso/sessionManager/userSession.d.ts +19 -2
- package/dist/sso/sessionManager/userSession.js +45 -2
- package/dist/sso/userSecretRepository.js +2 -1
- package/dist/sso/userSessionRepository.js +13 -8
- package/dist/sso/userSessionRepository.spec.js +4 -4
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -35,10 +35,6 @@ const papp = createPappAdapter({
|
|
|
35
35
|
// otherwise existing pairings will be lost.
|
|
36
36
|
appId: 'my-host-app',
|
|
37
37
|
|
|
38
|
-
// URL to a JSON document describing the host: { name: string, icon: string }.
|
|
39
|
-
// The icon should be a rasterized image at least 256x256 px.
|
|
40
|
-
metadata: 'https://my-host-app.example/papp-metadata.json',
|
|
41
|
-
|
|
42
38
|
// Optional environment metadata shown on the wallet's confirmation screen.
|
|
43
39
|
hostMetadata: {
|
|
44
40
|
hostVersion: '1.4.0',
|
|
@@ -48,14 +44,15 @@ const papp = createPappAdapter({
|
|
|
48
44
|
});
|
|
49
45
|
```
|
|
50
46
|
|
|
51
|
-
`createPappAdapter` returns
|
|
47
|
+
`createPappAdapter` returns five sub-modules:
|
|
52
48
|
|
|
53
|
-
| Module
|
|
54
|
-
|
|
|
55
|
-
| `papp.sso`
|
|
56
|
-
| `papp.sessions`
|
|
57
|
-
| `papp.secrets`
|
|
58
|
-
| `papp.identity`
|
|
49
|
+
| Module | Purpose |
|
|
50
|
+
| ---------------- | ------------------------------------------------------------------- |
|
|
51
|
+
| `papp.sso` | Authentication / pairing flow with a remote wallet. |
|
|
52
|
+
| `papp.sessions` | List of paired user sessions and per-session messaging (sign, etc.).|
|
|
53
|
+
| `papp.secrets` | Local secret storage for the derived guest accounts. |
|
|
54
|
+
| `papp.identity` | On-chain identity lookups for arbitrary account ids. |
|
|
55
|
+
| `papp.allowance` | Resource allowances (bulletin / statement-store signers) per product.|
|
|
59
56
|
|
|
60
57
|
Custom adapters (statement store, identity RPC, storage, lazy chain client) can be supplied
|
|
61
58
|
via the `adapters` option for testing or non-browser environments.
|
|
@@ -200,6 +197,34 @@ await currentSession.signRaw({
|
|
|
200
197
|
});
|
|
201
198
|
```
|
|
202
199
|
|
|
200
|
+
`signVrf` asks the wallet for an sr25519 (schnorrkel) VRF signature from a product account
|
|
201
|
+
(RFC-0023). The transcript travels as a recipe — a root domain-separation label plus an
|
|
202
|
+
ordered list of `(label, value)` items — which the wallet replays verbatim into a Merlin
|
|
203
|
+
transcript and signs. Callers that need a `signer` item must supply their own public key;
|
|
204
|
+
the host never injects it.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const encoder = new TextEncoder();
|
|
208
|
+
|
|
209
|
+
const vrf = await currentSession.signVrf({
|
|
210
|
+
productAccountId: ['product.dot', 0],
|
|
211
|
+
productId: 'product.dot',
|
|
212
|
+
transcriptLabel: encoder.encode('pop:airdrop'),
|
|
213
|
+
items: [
|
|
214
|
+
{ label: encoder.encode('domain'), value: domainBytes },
|
|
215
|
+
{ label: encoder.encode('signer'), value: accountPublicKey },
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
vrf.match(
|
|
220
|
+
({ preOutput, proof }) => submitLotteryTicket(preOutput, proof),
|
|
221
|
+
error => console.error('VRF signing failed:', error),
|
|
222
|
+
);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
This is the non-`AutoSigning` path only: when `AutoSigning` covers the account the host
|
|
226
|
+
signs locally and never round-trips to the wallet.
|
|
227
|
+
|
|
203
228
|
## Ring VRF proofs and aliases
|
|
204
229
|
|
|
205
230
|
A `UserSession` can ask the paired device for a privacy-preserving contextual alias, or a
|
|
@@ -208,7 +233,10 @@ selects the member key for the ring; `callingProductId` names the product the ho
|
|
|
208
233
|
for. Both take the same `(context, ring)` so the alias in the proof matches `getRingVrfAlias`.
|
|
209
234
|
|
|
210
235
|
```ts
|
|
211
|
-
|
|
236
|
+
// [productId, suffix]. The suffix is the wire `Index(u32) | Raw([u8; 32])` selector
|
|
237
|
+
// (RFC 0022): `Index` for a plain index, `Raw` for a raw 32-byte index. It
|
|
238
|
+
// expands to the same 32-byte value as a product account's derivation index.
|
|
239
|
+
const context = ['product.dot', { tag: 'Index', value: 0 }];
|
|
212
240
|
const ring = {
|
|
213
241
|
chainId: '0x…', // 32-byte chain genesis hash
|
|
214
242
|
junctions: [{ tag: 'PalletInstance', value: 42 }],
|
|
@@ -225,6 +253,28 @@ proof.match(
|
|
|
225
253
|
);
|
|
226
254
|
```
|
|
227
255
|
|
|
256
|
+
## Product subtree public keys
|
|
257
|
+
|
|
258
|
+
Product accounts live at `//product//{productId}/{index}` (RFC 0022). The
|
|
259
|
+
product junction is **hard**, so the user's root public key alone no longer
|
|
260
|
+
determines product account public keys — the host asks the paired device for the
|
|
261
|
+
product-subtree public key once, then soft-derives account public keys locally
|
|
262
|
+
from it.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
const subtreeKey = await currentSession.getProductSubtree('product.dot');
|
|
266
|
+
|
|
267
|
+
subtreeKey.match(
|
|
268
|
+
publicKey => cacheProductSubtree('product.dot', publicKey), // one round trip per product, ever
|
|
269
|
+
error => console.error('subtree lookup failed:', error),
|
|
270
|
+
);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The request is consent-free — the response carries no secret material. Only
|
|
274
|
+
`AutoSigning` does: its payload is now the product-subtree secret key alone
|
|
275
|
+
(`productRootPrivateKey`, 64-byte expanded sr25519 secret), which exposes exactly
|
|
276
|
+
that product's subtree. The former `productDerivationSecret` is gone.
|
|
277
|
+
|
|
228
278
|
## Identity lookups
|
|
229
279
|
|
|
230
280
|
`papp.identity` resolves on-chain identity data (lite / full username, credibility, slots)
|
|
@@ -246,6 +296,13 @@ const lookup = async (accountId: string) => {
|
|
|
246
296
|
await papp.identity.getIdentities([accountIdA, accountIdB]);
|
|
247
297
|
```
|
|
248
298
|
|
|
299
|
+
A paired `UserSession` also exposes `getIdentity()` as a shortcut that looks up the identity
|
|
300
|
+
of its own user identity account — no account id to pass:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const identity = await session.getIdentity(); // Result<Identity | null, Error>
|
|
304
|
+
```
|
|
305
|
+
|
|
249
306
|
## V2 SSO handshake
|
|
250
307
|
|
|
251
308
|
V2 is a redesign of the SSO pairing flow that supports the same user identity across
|
|
@@ -258,38 +315,15 @@ identity, so contacts, chats, and roster events are shared between them.
|
|
|
258
315
|
V2 is **not interoperable with V1**: a V1-only peer can't decode a V2 proposal QR and vice
|
|
259
316
|
versa. Hosts that want to support both should branch on which protocol the peer advertises.
|
|
260
317
|
|
|
261
|
-
###
|
|
262
|
-
|
|
263
|
-
```
|
|
264
|
-
host peer (authorising device)
|
|
265
|
-
────────────────────────────────────────────────────────────────────────
|
|
266
|
-
buildPairingDeeplink(device, metadata)
|
|
267
|
-
→ polkadotapp://pair?handshake=<hex>
|
|
268
|
-
scan QR, decode proposal
|
|
269
|
-
compute pairing topic from
|
|
270
|
-
the host's pubkeys
|
|
271
|
-
ECDH-encrypt + post:
|
|
272
|
-
Pending(AllowanceAllocation)
|
|
273
|
-
Success { encryptionKey,
|
|
274
|
-
accountId,
|
|
275
|
-
identitySignature }
|
|
276
|
-
Failed(reason)
|
|
277
|
-
service.subscribeStatements(topic) +
|
|
278
|
-
poll the topic every 2s
|
|
279
|
-
↓
|
|
280
|
-
decode VersionedHandshakeResponse::V2
|
|
281
|
-
→ ECDH-decrypt envelope with the
|
|
282
|
-
device encryption private key
|
|
283
|
-
→ SCALE-decode inner payload
|
|
284
|
-
→ state machine: Submitted → Pending →
|
|
285
|
-
Success | Failed
|
|
286
|
-
→ on Success persist user identity
|
|
287
|
-
```
|
|
318
|
+
### The flow
|
|
288
319
|
|
|
289
|
-
The
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
320
|
+
1. The host builds a pairing deeplink from its device keypair and shows it as a QR code.
|
|
321
|
+
2. The authorising device scans it and posts its response to the Statement Store: first a
|
|
322
|
+
`Pending` acknowledgement, then either `Success` — carrying the user's identity keys,
|
|
323
|
+
signed to authorise this device — or `Failed`.
|
|
324
|
+
3. The host polls the pairing topic, decrypts and verifies each response, and drives a
|
|
325
|
+
`Submitted → Pending → Success | Failed` state machine. On `Success` it persists the
|
|
326
|
+
user identity.
|
|
293
327
|
|
|
294
328
|
### Building and rendering the QR
|
|
295
329
|
|
|
@@ -318,7 +352,7 @@ renderQrCode(deeplink); // 'polkadotapp://pair?handshake=<hex>'
|
|
|
318
352
|
import { startPairingV2 } from '@novasamatech/host-papp';
|
|
319
353
|
|
|
320
354
|
const pairing = startPairingV2({
|
|
321
|
-
statementStore
|
|
355
|
+
statementStore, // any StatementStoreAdapter
|
|
322
356
|
deviceIdentity: {
|
|
323
357
|
statementAccountPublicKey: device.statementAccountPublicKey,
|
|
324
358
|
encryptionPublicKey: device.encryptionPublicKey,
|
|
@@ -380,33 +414,14 @@ The service skips any incoming statement whose bytes match `initialProcessedData
|
|
|
380
414
|
re-encrypts every Success with a fresh ephemeral key + AES-GCM nonce, so a genuine re-pair
|
|
381
415
|
always produces different bytes and passes the dedupe.
|
|
382
416
|
|
|
383
|
-
|
|
417
|
+
## Reading allowances
|
|
384
418
|
|
|
385
|
-
|
|
386
|
-
|
|
419
|
+
Each `UserSession` can read its own persisted allowance slot-account key for a given
|
|
420
|
+
product and resource. The session id is implicit — you only pass the product and resource:
|
|
387
421
|
|
|
388
422
|
```ts
|
|
389
|
-
|
|
423
|
+
const session = papp.sessions.sessions.read().at(0);
|
|
390
424
|
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
// topic = blake2b256_keyed(encryptionPublicKey || "topic", key=statementAccountId)
|
|
394
|
-
// channel = blake2b256_keyed(encryptionPublicKey || "channel", key=statementAccountId)
|
|
425
|
+
// resource: 'bulletin' | 'statementStore'
|
|
426
|
+
const key = await session.readAllowance(productId, 'statementStore'); // Result<Uint8Array | null, Error>
|
|
395
427
|
```
|
|
396
|
-
|
|
397
|
-
### Codec exports
|
|
398
|
-
|
|
399
|
-
The SCALE codecs are exported as plain `Codec<T>` values for callers that need to
|
|
400
|
-
encode/decode statements outside the orchestrator:
|
|
401
|
-
|
|
402
|
-
| Export | Description |
|
|
403
|
-
| --------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
404
|
-
| `VersionedHandshakeProposal` | Outer enum; V2 at SCALE discriminant 1, with `_v1Reserved` at 0. |
|
|
405
|
-
| `HandshakeProposalV2` | `{ device, metadata }` — what the QR encodes. |
|
|
406
|
-
| `Device` | `{ statementAccountId(32), encryptionPublicKey(65) }`. |
|
|
407
|
-
| `MetadataKey`, `MetadataEntry` | Metadata enum + `(MetadataKey, str)` tuple. |
|
|
408
|
-
| `VersionedHandshakeResponse` | Outer enum for the answer; `V1` legacy + `V2`. |
|
|
409
|
-
| `HandshakeResponseV2` | `{ encrypted, tmpKey(65) }` — the ECDH-wrapped envelope. |
|
|
410
|
-
| `EncryptedHandshakeResponseV2` | Inner payload after envelope decrypt: `Pending` (1 byte), `Success` (161 bytes), `Failed`. |
|
|
411
|
-
| `HandshakeSuccessV2` | `{ encryptionKey(65), accountId(32), identitySignature(64) }`. |
|
|
412
|
-
| `IDENTITY_SIGNATURE_PAYLOAD_BYTES`| `97` — the bytes the user identity sr25519 signs over. |
|
package/dist/crypto.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
2
|
+
import { Bytes } from '@novasamatech/scale';
|
|
2
3
|
import { createSr25519Secret, deriveSr25519PublicKey, signWithSr25519Secret, verifySr25519Signature, } from '@novasamatech/statement-store';
|
|
3
4
|
import { entropyToMiniSecret, mnemonicToEntropy } from '@polkadot-labs/hdkd-helpers';
|
|
4
|
-
import { Bytes } from 'scale-ts';
|
|
5
5
|
// schemas
|
|
6
6
|
export function BrandedBytesCodec(length) {
|
|
7
7
|
return Bytes(length);
|
|
8
8
|
}
|
|
9
9
|
export const SsPubKey = BrandedBytesCodec(32);
|
|
10
|
-
export const EncrPubKey = BrandedBytesCodec(
|
|
10
|
+
export const EncrPubKey = BrandedBytesCodec(32);
|
|
11
11
|
// helpers
|
|
12
12
|
const textEncoder = new TextEncoder();
|
|
13
13
|
export function stringToBytes(str) {
|
|
@@ -27,16 +27,13 @@ export function deriveSr25519Account(mnemonic, derivation) {
|
|
|
27
27
|
}
|
|
28
28
|
// encryption key pair
|
|
29
29
|
export function createEncrSecret(entropy) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
seed.set(miniSecret);
|
|
33
|
-
const { secretKey } = p256.keygen(seed);
|
|
34
|
-
return secretKey;
|
|
30
|
+
// The 32-byte mini-secret is the X25519 private scalar (clamped internally by @noble on use).
|
|
31
|
+
return entropyToMiniSecret(entropy);
|
|
35
32
|
}
|
|
36
33
|
export function getEncrPub(secret) {
|
|
37
|
-
return
|
|
34
|
+
return x25519.getPublicKey(secret);
|
|
38
35
|
}
|
|
39
36
|
export function createSharedSecret(secret, publicKey) {
|
|
40
|
-
//
|
|
41
|
-
return
|
|
37
|
+
// The X25519 output is used whole. @noble aborts on an all-zero (small-order) result per RFC 7748.
|
|
38
|
+
return x25519.getSharedSecret(secret, publicKey);
|
|
42
39
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSharedSecret, getEncrPub } from './crypto.js';
|
|
3
|
+
// RFC 7748 §5.2 X25519 test vector.
|
|
4
|
+
const fromHex = (hex) => Uint8Array.from(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
5
|
+
const ALICE_PRIVATE = '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a';
|
|
6
|
+
const ALICE_PUBLIC = '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a';
|
|
7
|
+
const BOB_PRIVATE = '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb';
|
|
8
|
+
const BOB_PUBLIC = 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f';
|
|
9
|
+
const SHARED_SECRET = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742';
|
|
10
|
+
const asSecret = (hex) => fromHex(hex);
|
|
11
|
+
describe('X25519 encryption keys', () => {
|
|
12
|
+
it('derives a 32-byte X25519 public key from a private scalar (RFC 7748 vector)', () => {
|
|
13
|
+
const publicKey = getEncrPub(asSecret(ALICE_PRIVATE));
|
|
14
|
+
expect(publicKey.length).toBe(32);
|
|
15
|
+
expect(publicKey).toEqual(fromHex(ALICE_PUBLIC));
|
|
16
|
+
});
|
|
17
|
+
it('computes the RFC 7748 shared secret whole, without slicing', () => {
|
|
18
|
+
const shared = createSharedSecret(asSecret(ALICE_PRIVATE), fromHex(BOB_PUBLIC));
|
|
19
|
+
expect(shared.length).toBe(32);
|
|
20
|
+
expect(shared).toEqual(fromHex(SHARED_SECRET));
|
|
21
|
+
});
|
|
22
|
+
it('agrees on the same shared secret from either side', () => {
|
|
23
|
+
const fromAlice = createSharedSecret(asSecret(ALICE_PRIVATE), fromHex(BOB_PUBLIC));
|
|
24
|
+
const fromBob = createSharedSecret(asSecret(BOB_PRIVATE), fromHex(ALICE_PUBLIC));
|
|
25
|
+
expect(fromAlice).toEqual(fromBob);
|
|
26
|
+
});
|
|
27
|
+
it('rejects a small-order public key (all-zero shared secret)', () => {
|
|
28
|
+
const smallOrderPoint = new Uint8Array(32);
|
|
29
|
+
expect(() => createSharedSecret(asSecret(ALICE_PRIVATE), smallOrderPoint)).toThrow();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { setTimeout } from 'node:timers/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { fromPromise, okAsync } from 'neverthrow';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { createAsyncTaskPool } from './createAsyncTaskPool.js';
|
|
5
5
|
import { toError } from './utils.js';
|
|
@@ -8,7 +8,7 @@ describe('asyncTaskPool', () => {
|
|
|
8
8
|
it('should exec async task', async () => {
|
|
9
9
|
const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: () => 0 });
|
|
10
10
|
const result = await pool.call(() => fromPromise(delay().then(() => 'test'), toError));
|
|
11
|
-
expect(result).
|
|
11
|
+
await expect(result).toBeOkWith('test');
|
|
12
12
|
});
|
|
13
13
|
it('should handle sync errors', async () => {
|
|
14
14
|
const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: () => 0 });
|
|
@@ -16,13 +16,13 @@ describe('asyncTaskPool', () => {
|
|
|
16
16
|
const result = await pool.call(() => {
|
|
17
17
|
throw error;
|
|
18
18
|
});
|
|
19
|
-
expect(result).
|
|
19
|
+
await expect(result).toBeErrWith(error);
|
|
20
20
|
});
|
|
21
21
|
it('should handle async errors', async () => {
|
|
22
22
|
const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: () => 0 });
|
|
23
23
|
const error = new Error('test');
|
|
24
24
|
const result = await pool.call(() => fromPromise(Promise.reject(error), toError));
|
|
25
|
-
|
|
25
|
+
await expect(result).toBeErrWith(error);
|
|
26
26
|
});
|
|
27
27
|
it('should handle queue', async () => {
|
|
28
28
|
const pool = createAsyncTaskPool({ poolSize: 2, retryCount: 0, retryDelay: () => 0 });
|
|
@@ -52,7 +52,7 @@ describe('asyncTaskPool', () => {
|
|
|
52
52
|
tries++;
|
|
53
53
|
throw new Error();
|
|
54
54
|
});
|
|
55
|
-
expect(result).
|
|
55
|
+
await expect(result).toBeOkWith('test');
|
|
56
56
|
});
|
|
57
57
|
it('should throw on retry limit exceeding', async () => {
|
|
58
58
|
const spy = vi.fn(() => 0);
|
|
@@ -66,7 +66,7 @@ describe('asyncTaskPool', () => {
|
|
|
66
66
|
throw new Error();
|
|
67
67
|
});
|
|
68
68
|
expect(spy).toBeCalledTimes(1);
|
|
69
|
-
expect(result).
|
|
69
|
+
await expect(result).toBeErrWith(new Error());
|
|
70
70
|
});
|
|
71
71
|
it('should correctly calculate retry delay', async () => {
|
|
72
72
|
const spy = vi.fn((retry) => retry * 10);
|
|
@@ -125,9 +125,9 @@ describe('asyncTaskPool', () => {
|
|
|
125
125
|
const queued = pool.call(queuedSpy, { signal: controller.signal });
|
|
126
126
|
controller.abort();
|
|
127
127
|
const queuedResult = await queued;
|
|
128
|
-
expect(queuedResult
|
|
128
|
+
await expect(queuedResult).toBeErr();
|
|
129
129
|
expect(queuedSpy).not.toHaveBeenCalled();
|
|
130
|
-
expect(
|
|
130
|
+
await expect(await active).toBeOk();
|
|
131
131
|
});
|
|
132
132
|
it('rejects the in-flight active task when the signal aborts', async () => {
|
|
133
133
|
const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
|
|
@@ -136,7 +136,7 @@ describe('asyncTaskPool', () => {
|
|
|
136
136
|
signal: controller.signal,
|
|
137
137
|
});
|
|
138
138
|
controller.abort();
|
|
139
|
-
expect(
|
|
139
|
+
await expect(await active).toBeErr();
|
|
140
140
|
});
|
|
141
141
|
it('frees the slot for later tasks after an abort', async () => {
|
|
142
142
|
const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
|
|
@@ -147,7 +147,7 @@ describe('asyncTaskPool', () => {
|
|
|
147
147
|
controller.abort();
|
|
148
148
|
await aborted;
|
|
149
149
|
const next = await pool.call(() => okAsync('next'));
|
|
150
|
-
expect(next).
|
|
150
|
+
await expect(next).toBeOkWith('next');
|
|
151
151
|
});
|
|
152
152
|
it('rejects immediately when called with an already-aborted signal', async () => {
|
|
153
153
|
const pool = createAsyncTaskPool({ poolSize: 1, retryCount: 0, retryDelay: 0 });
|
|
@@ -155,7 +155,7 @@ describe('asyncTaskPool', () => {
|
|
|
155
155
|
controller.abort();
|
|
156
156
|
const spy = vi.fn(() => okAsync('x'));
|
|
157
157
|
const result = await pool.call(spy, { signal: controller.signal });
|
|
158
|
-
expect(result
|
|
158
|
+
await expect(result).toBeErr();
|
|
159
159
|
expect(spy).not.toHaveBeenCalled();
|
|
160
160
|
});
|
|
161
161
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -6,9 +6,12 @@ export type { PairingStatus } from './sso/auth/types.js';
|
|
|
6
6
|
export type { DeviceIdentityForPairing } from './sso/auth/v2/service.js';
|
|
7
7
|
export type { AllowanceErrorReason, AllowanceService } from './sso/allowance/index.js';
|
|
8
8
|
export { AllowanceError } from './sso/allowance/index.js';
|
|
9
|
+
export type { AllowanceResourceKind } from './sso/allowance/index.js';
|
|
9
10
|
export type { UserSession } from './sso/sessionManager/userSession.js';
|
|
10
11
|
export type { StoredUserSession } from './sso/userSessionRepository.js';
|
|
11
12
|
export type { Identity } from './identity/types.js';
|
|
12
13
|
export type { SignRawLegacyRequest, SignRawLegacyResponse, SigningPayloadRequest, SigningPayloadResponse, SigningRawRequest, SigningRequest, } from './sso/sessionManager/scale/signing.js';
|
|
13
14
|
export type { RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, RingVrfProofResponse, } from './sso/sessionManager/scale/ringVrf.js';
|
|
15
|
+
export type { SignVrfErr, SignVrfRequest, SignVrfResponse } from './sso/sessionManager/scale/signVrf.js';
|
|
14
16
|
export type { CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, } from './sso/sessionManager/scale/createTransaction.js';
|
|
17
|
+
export type { ProductSubtreeRequest, ProductSubtreeResponse } from './sso/sessionManager/scale/productSubtree.js';
|
package/dist/papp.js
CHANGED
|
@@ -19,6 +19,7 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
|
|
|
19
19
|
const ssoSessionRepository = createUserSessionRepository(storage);
|
|
20
20
|
const userSecretRepository = createUserSecretRepository(appId, storage);
|
|
21
21
|
const allowanceRepository = createAllowanceRepository(appId, storage);
|
|
22
|
+
const identityRepository = createIdentityRepository({ adapter: identities, storage });
|
|
22
23
|
const deviceIdentityStore = createDeviceIdentityStore(appId, storage);
|
|
23
24
|
const sessions = createSsoSessionManager({
|
|
24
25
|
storage,
|
|
@@ -26,6 +27,7 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
|
|
|
26
27
|
ssoSessionRepository,
|
|
27
28
|
userSecretRepository,
|
|
28
29
|
allowanceRepository,
|
|
30
|
+
identityRepository,
|
|
29
31
|
});
|
|
30
32
|
return {
|
|
31
33
|
sso: createAuth({
|
|
@@ -39,7 +41,7 @@ export function createPappAdapter({ appId, hostMetadata, deviceIdentity, onAuthS
|
|
|
39
41
|
}),
|
|
40
42
|
sessions,
|
|
41
43
|
secrets: userSecretRepository,
|
|
42
|
-
identity:
|
|
44
|
+
identity: identityRepository,
|
|
43
45
|
allowance: createAllowanceService({ sessions: sessions.sessions, repository: allowanceRepository }),
|
|
44
46
|
};
|
|
45
47
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { gcm } from '@noble/ciphers/aes.js';
|
|
2
2
|
import { blake2b } from '@noble/hashes/blake2.js';
|
|
3
|
+
import { Bytes } from '@novasamatech/scale';
|
|
3
4
|
import { fromThrowable } from 'neverthrow';
|
|
4
5
|
import { fromHex, toHex } from 'polkadot-api/utils';
|
|
5
|
-
import {
|
|
6
|
+
import { Enum, Struct, Vector, _void, str } from 'scale-ts';
|
|
6
7
|
import { stringToBytes } from '../../crypto.js';
|
|
7
8
|
import { toError } from '../../helpers/utils.js';
|
|
8
9
|
const AllowanceResourceKindCodec = Enum({
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
* for soft-derivation of product accounts; PApp
|
|
17
17
|
* and host MUST derive identically so a dapp sees
|
|
18
18
|
* the same address on every device.
|
|
19
|
-
* - `identityChatPrivateKey`— user identity chat
|
|
19
|
+
* - `identityChatPrivateKey`— user identity chat X25519 private scalar (32 bytes),
|
|
20
20
|
* shared per the multi-device spec so this device
|
|
21
21
|
* can decrypt traffic addressed to the user identity
|
|
22
|
-
* - `deviceEncPubKey` — encryption public key of the PApp device (
|
|
23
|
-
*
|
|
22
|
+
* - `deviceEncPubKey` — encryption public key of the PApp device (32 bytes,
|
|
23
|
+
* X25519). Tells the host which key to
|
|
24
24
|
* use when addressing chat envelopes back to the
|
|
25
25
|
* authorising PApp device.
|
|
26
|
-
* - `ssoEncPubKey` — `papp_encr_pub` (
|
|
26
|
+
* - `ssoEncPubKey` — `papp_encr_pub` (32 bytes, X25519); see
|
|
27
27
|
* the `HandshakeSuccessV2` codec doc below.
|
|
28
28
|
* - `rootEntropySource` — 32 bytes; `blake2b256_keyed(rootAccountSecret,
|
|
29
29
|
* "product-entropy-derivation")` per RFC-0007 (layer 1).
|
|
@@ -137,7 +137,7 @@ export declare const HandshakeSuccessV2: import("scale-ts").Codec<{
|
|
|
137
137
|
deviceEncPubKey: Uint8Array<ArrayBufferLike>;
|
|
138
138
|
rootEntropySource: Uint8Array<ArrayBufferLike>;
|
|
139
139
|
}>;
|
|
140
|
-
/** Derive the identity chat
|
|
140
|
+
/** Derive the identity chat X25519 public key (32 bytes) from its private scalar. */
|
|
141
141
|
export declare const deriveIdentityChatPublicKey: (privateKey: Uint8Array) => Uint8Array;
|
|
142
142
|
export declare const HandshakeStatusV2: import("scale-ts").Codec<{
|
|
143
143
|
tag: "AllowanceAllocation";
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
* for soft-derivation of product accounts; PApp
|
|
17
17
|
* and host MUST derive identically so a dapp sees
|
|
18
18
|
* the same address on every device.
|
|
19
|
-
* - `identityChatPrivateKey`— user identity chat
|
|
19
|
+
* - `identityChatPrivateKey`— user identity chat X25519 private scalar (32 bytes),
|
|
20
20
|
* shared per the multi-device spec so this device
|
|
21
21
|
* can decrypt traffic addressed to the user identity
|
|
22
|
-
* - `deviceEncPubKey` — encryption public key of the PApp device (
|
|
23
|
-
*
|
|
22
|
+
* - `deviceEncPubKey` — encryption public key of the PApp device (32 bytes,
|
|
23
|
+
* X25519). Tells the host which key to
|
|
24
24
|
* use when addressing chat envelopes back to the
|
|
25
25
|
* authorising PApp device.
|
|
26
|
-
* - `ssoEncPubKey` — `papp_encr_pub` (
|
|
26
|
+
* - `ssoEncPubKey` — `papp_encr_pub` (32 bytes, X25519); see
|
|
27
27
|
* the `HandshakeSuccessV2` codec doc below.
|
|
28
28
|
* - `rootEntropySource` — 32 bytes; `blake2b256_keyed(rootAccountSecret,
|
|
29
29
|
* "product-entropy-derivation")` per RFC-0007 (layer 1).
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
* (`host_derive_entropy`) without ever holding the raw
|
|
32
32
|
* root account secret. See the codec doc below.
|
|
33
33
|
*/
|
|
34
|
-
import {
|
|
35
|
-
import { Bytes
|
|
34
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
35
|
+
import { Bytes } from '@novasamatech/scale';
|
|
36
|
+
import { Enum, Struct, Tuple, Vector, _void, str } from 'scale-ts';
|
|
36
37
|
const AccountIdCodec = Bytes(32);
|
|
37
|
-
const PublicKeyCodec = Bytes(
|
|
38
|
+
const PublicKeyCodec = Bytes(32);
|
|
38
39
|
const PrivateKeyCodec = Bytes(32);
|
|
39
40
|
const EntropySourceCodec = Bytes(32);
|
|
40
41
|
export const MetadataKey = Enum({
|
|
@@ -63,14 +64,14 @@ export const HandshakeSuccessV2 = Struct({
|
|
|
63
64
|
identityAccountId: AccountIdCodec,
|
|
64
65
|
rootAccountId: AccountIdCodec,
|
|
65
66
|
identityChatPrivateKey: PrivateKeyCodec,
|
|
66
|
-
/** PApp's
|
|
67
|
+
/** PApp's X25519 SSO ECDH public key (`papp_encr_pub`). */
|
|
67
68
|
ssoEncPubKey: PublicKeyCodec,
|
|
68
69
|
deviceEncPubKey: PublicKeyCodec,
|
|
69
70
|
/** Layer-1 source for deterministic product entropy derivation (RFC-0007). */
|
|
70
71
|
rootEntropySource: EntropySourceCodec,
|
|
71
72
|
});
|
|
72
|
-
/** Derive the identity chat
|
|
73
|
-
export const deriveIdentityChatPublicKey = (privateKey) =>
|
|
73
|
+
/** Derive the identity chat X25519 public key (32 bytes) from its private scalar. */
|
|
74
|
+
export const deriveIdentityChatPublicKey = (privateKey) => x25519.getPublicKey(privateKey);
|
|
74
75
|
export const HandshakeStatusV2 = Enum({
|
|
75
76
|
AllowanceAllocation: _void,
|
|
76
77
|
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Decrypt the outer envelope of a `HandshakeResponseV2` statement payload.
|
|
3
3
|
*
|
|
4
|
-
* The answering side generates a one-shot
|
|
5
|
-
* the host device's encryption public key, and
|
|
6
|
-
* payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a
|
|
7
|
-
* derived from the shared secret.
|
|
4
|
+
* The answering side generates a one-shot X25519 keypair, performs ECDH against
|
|
5
|
+
* the host device's encryption public key, and ChaCha20-Poly1305 encrypts the
|
|
6
|
+
* sensitive payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a
|
|
7
|
+
* key derived from the shared secret.
|
|
8
8
|
*
|
|
9
|
-
* The shared-secret-to-
|
|
10
|
-
*
|
|
9
|
+
* The shared-secret-to-AEAD-key derivation (HKDF-SHA256 over the X25519 shared
|
|
10
|
+
* secret) is delegated to `createEncryption(sharedSecret)` from
|
|
11
11
|
* `@novasamatech/statement-store` — byte-compatible with the existing V1
|
|
12
12
|
* chat-request encryption helper, so we don't fork primitives here.
|
|
13
13
|
*/
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Decrypt the outer envelope of a `HandshakeResponseV2` statement payload.
|
|
3
3
|
*
|
|
4
|
-
* The answering side generates a one-shot
|
|
5
|
-
* the host device's encryption public key, and
|
|
6
|
-
* payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a
|
|
7
|
-
* derived from the shared secret.
|
|
4
|
+
* The answering side generates a one-shot X25519 keypair, performs ECDH against
|
|
5
|
+
* the host device's encryption public key, and ChaCha20-Poly1305 encrypts the
|
|
6
|
+
* sensitive payload (the SCALE-encoded `EncryptedHandshakeResponseV2`) with a
|
|
7
|
+
* key derived from the shared secret.
|
|
8
8
|
*
|
|
9
|
-
* The shared-secret-to-
|
|
10
|
-
*
|
|
9
|
+
* The shared-secret-to-AEAD-key derivation (HKDF-SHA256 over the X25519 shared
|
|
10
|
+
* secret) is delegated to `createEncryption(sharedSecret)` from
|
|
11
11
|
* `@novasamatech/statement-store` — byte-compatible with the existing V1
|
|
12
12
|
* chat-request encryption helper, so we don't fork primitives here.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
15
15
|
import { createEncryption } from '@novasamatech/statement-store';
|
|
16
|
-
const
|
|
16
|
+
const ecdh = (privateKey, peerPublicKey) => x25519.getSharedSecret(privateKey, peerPublicKey);
|
|
17
17
|
export const decryptResponseEnvelope = (deviceEncryptionPrivateKey, envelope) => {
|
|
18
|
-
const shared =
|
|
18
|
+
const shared = ecdh(deviceEncryptionPrivateKey, envelope.tmpKey);
|
|
19
19
|
const result = createEncryption(shared).decrypt(envelope.encrypted);
|
|
20
20
|
if (result.isErr())
|
|
21
21
|
throw result.error;
|
|
@@ -29,8 +29,8 @@ export type HandshakePendingState = {
|
|
|
29
29
|
export type HandshakeSuccessState = CodecType<typeof HandshakeSuccessV2> & {
|
|
30
30
|
tag: 'Success';
|
|
31
31
|
/**
|
|
32
|
-
* Derived locally from `identityChatPrivateKey` via
|
|
33
|
-
* multiplication (
|
|
32
|
+
* Derived locally from `identityChatPrivateKey` via X25519 scalar
|
|
33
|
+
* multiplication (32-byte form). Both sides MUST derive
|
|
34
34
|
* identically; downstream session topics depend on it.
|
|
35
35
|
*/
|
|
36
36
|
identityChatPublicKey: Uint8Array;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Where:
|
|
8
8
|
* - `statementAccountId` = the host's sr25519 device public key (32 bytes)
|
|
9
|
-
* - `encryptionPublicKey` = the host's
|
|
9
|
+
* - `encryptionPublicKey` = the host's X25519 device public key (32 bytes)
|
|
10
10
|
*
|
|
11
11
|
* Both sides compute the same topic/channel deterministically from the same
|
|
12
12
|
* pubkeys carried in the QR-coded `VersionedHandshakeProposal::V2`, so they
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Where:
|
|
8
8
|
* - `statementAccountId` = the host's sr25519 device public key (32 bytes)
|
|
9
|
-
* - `encryptionPublicKey` = the host's
|
|
9
|
+
* - `encryptionPublicKey` = the host's X25519 device public key (32 bytes)
|
|
10
10
|
*
|
|
11
11
|
* Both sides compute the same topic/channel deterministically from the same
|
|
12
12
|
* pubkeys carried in the QR-coded `VersionedHandshakeProposal::V2`, so they
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { gcm } from '@noble/ciphers/aes.js';
|
|
2
2
|
import { blake2b } from '@noble/hashes/blake2.js';
|
|
3
|
+
import { Bytes } from '@novasamatech/scale';
|
|
3
4
|
import { createSr25519Secret, deriveSr25519PublicKey } from '@novasamatech/statement-store';
|
|
4
5
|
import { errAsync, fromPromise, okAsync } from 'neverthrow';
|
|
5
6
|
import { fromHex, toHex } from 'polkadot-api/utils';
|
|
6
|
-
import {
|
|
7
|
+
import { Option, Struct, str } from 'scale-ts';
|
|
7
8
|
import { getEncrPub, stringToBytes } from '../crypto.js';
|
|
8
9
|
import { toError } from '../helpers/utils.js';
|
|
9
10
|
const KEY = 'DeviceIdentity';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StatementStoreAdapter } from '@novasamatech/statement-store';
|
|
2
2
|
import type { StorageAdapter } from '@novasamatech/storage-adapter';
|
|
3
|
+
import type { IdentityRepository } from '../../identity/types.js';
|
|
3
4
|
import type { Callback } from '../../types.js';
|
|
4
5
|
import type { AllowanceRepository } from '../allowance/index.js';
|
|
5
6
|
import type { UserSecretRepository } from '../userSecretRepository.js';
|
|
@@ -12,8 +13,9 @@ type Params = {
|
|
|
12
13
|
ssoSessionRepository: UserSessionRepository;
|
|
13
14
|
userSecretRepository: UserSecretRepository;
|
|
14
15
|
allowanceRepository: AllowanceRepository;
|
|
16
|
+
identityRepository: IdentityRepository;
|
|
15
17
|
};
|
|
16
|
-
export declare function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, statementStore, storage, }: Params): {
|
|
18
|
+
export declare function createSsoSessionManager({ ssoSessionRepository, userSecretRepository, allowanceRepository, identityRepository, statementStore, storage, }: Params): {
|
|
17
19
|
sessions: {
|
|
18
20
|
read: () => UserSession[];
|
|
19
21
|
subscribe: (callback: Callback<UserSession[]>) => () => void;
|