Skip to content

Commit 340c62d

Browse files
feat(host-papp,host-container): derive product entropy from rootEntropySource (RFC-7)
Wire the RFC-0007 `rootEntropySource` through the SSO handshake so a host can serve `host_derive_entropy` deterministically without holding the raw root account secret. - host-papp: append `rootEntropySource` ([u8; 32]) to the `HandshakeSuccessV2` body (258 bytes; null for older peers). This revision is not yet pinned in the Mobile SSO spec (latest defined is v0.2.2, kept as `HandshakeSuccessV2_v022`). Thread it through the success state and persist it in `userSecretRepository` as `Option(Bytes(32))`, exposed via `papp.secrets`. - host-container: add `deriveProductEntropyFromSource(rootEntropySource, productId, key)` (layers 2-3); `deriveProductEntropy` now delegates to it. Refs #204.
1 parent 7e5ada6 commit 340c62d

11 files changed

Lines changed: 265 additions & 30 deletions

File tree

__tests__/hostApi/deriveEntropy.spec.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { deriveProductEntropy } from '@novasamatech/host-container';
1+
import { deriveProductEntropy, deriveProductEntropyFromSource } from '@novasamatech/host-container';
22

33
import { blake2b } from '@noble/hashes/blake2.js';
44
import { describe, expect, it } from 'vitest';
@@ -79,3 +79,51 @@ describe('deriveProductEntropy', () => {
7979
expect(result.length).toBe(32);
8080
});
8181
});
82+
83+
describe('deriveProductEntropyFromSource', () => {
84+
const rootSecret = new Uint8Array(16).fill(0xab);
85+
const rootEntropySource = blake2b256Keyed(rootSecret, textEncoder.encode('product-entropy-derivation'));
86+
const productId = 'my-product.dot';
87+
const key = new Uint8Array([1, 2, 3]);
88+
89+
it('should match the last two layers of the RFC-0007 derivation', () => {
90+
const perProductEntropy = blake2b256Keyed(rootEntropySource, blake2b256(textEncoder.encode(productId)));
91+
const expectedEntropy = blake2b256Keyed(perProductEntropy, key);
92+
93+
expect(deriveProductEntropyFromSource(rootEntropySource, productId, key)).toEqual(expectedEntropy);
94+
});
95+
96+
it('should produce the same entropy as deriveProductEntropy given the corresponding rootAccountSecret', () => {
97+
// The cross-host determinism invariant: a host that only holds `rootEntropySource`
98+
// (received over the SSO handshake) derives the same bytes as a wallet that holds
99+
// the raw `rootAccountSecret`.
100+
for (const id of ['my-product.dot', 'other.product.dot', 'localhost:3000']) {
101+
for (const k of [new Uint8Array([0x42]), new Uint8Array(32).fill(0xff)]) {
102+
expect(deriveProductEntropyFromSource(rootEntropySource, id, k)).toEqual(
103+
deriveProductEntropy(rootSecret, id, k),
104+
);
105+
}
106+
}
107+
});
108+
109+
it('should produce different entropy for different sources, product ids, and keys', () => {
110+
const otherSource = blake2b256Keyed(
111+
new Uint8Array(16).fill(0xcd),
112+
textEncoder.encode('product-entropy-derivation'),
113+
);
114+
expect(deriveProductEntropyFromSource(rootEntropySource, productId, key)).not.toEqual(
115+
deriveProductEntropyFromSource(otherSource, productId, key),
116+
);
117+
expect(deriveProductEntropyFromSource(rootEntropySource, 'product-a', key)).not.toEqual(
118+
deriveProductEntropyFromSource(rootEntropySource, 'product-b', key),
119+
);
120+
expect(deriveProductEntropyFromSource(rootEntropySource, productId, new Uint8Array([1]))).not.toEqual(
121+
deriveProductEntropyFromSource(rootEntropySource, productId, new Uint8Array([2])),
122+
);
123+
});
124+
125+
it('should reject an empty key and a key bigger than 32 bytes', () => {
126+
expect(() => deriveProductEntropyFromSource(rootEntropySource, productId, new Uint8Array(0))).toThrow();
127+
expect(() => deriveProductEntropyFromSource(rootEntropySource, productId, new Uint8Array(33).fill(0x01))).toThrow();
128+
});
129+
});

packages/host-container/src/deriveEntropy.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { blake2b } from '@noble/hashes/blake2.js';
22

3+
const textEncoder = new TextEncoder();
4+
const DOMAIN_SEPARATOR = textEncoder.encode('product-entropy-derivation');
5+
36
function blake2b256Keyed(message: Uint8Array, key: Uint8Array): Uint8Array {
47
return blake2b(message, { dkLen: 32, key });
58
}
@@ -12,18 +15,47 @@ function blake2b256(message: Uint8Array): Uint8Array {
1215
* Derives 32 bytes of deterministic entropy using the three-layer
1316
* BLAKE2b-256 scheme specified in RFC-0007.
1417
*
18+
* Layer 1 derives the `rootEntropySource` from the raw root account secret.
19+
* Hosts that never hold the raw secret but receive `rootEntropySource` over the
20+
* SSO handshake (RFC-0007 "Option 1") should call
21+
* {@link deriveProductEntropyFromSource} instead — it skips layer 1 and yields
22+
* identical output.
23+
*
1524
* @param rootAccountSecret - Raw BIP-39 entropy bytes of the root account
1625
* @param productId - Identifier of the calling product (arbitrary-length string)
17-
* @param key - Caller-chosen key, up to 32 bytes
26+
* @param key - Caller-chosen key, 1 to 32 bytes
1827
*/
1928
export function deriveProductEntropy(rootAccountSecret: Uint8Array, productId: string, key: Uint8Array): Uint8Array {
29+
const rootEntropySource = blake2b256Keyed(rootAccountSecret, DOMAIN_SEPARATOR);
30+
return deriveProductEntropyFromSource(rootEntropySource, productId, key);
31+
}
32+
33+
/**
34+
* Derives 32 bytes of deterministic entropy from a precomputed
35+
* `rootEntropySource` — layers 2 and 3 of the RFC-0007 scheme.
36+
*
37+
* Use this when the host never holds the raw `rootAccountSecret` but receives
38+
* `rootEntropySource = blake2b256_keyed(rootAccountSecret, "product-entropy-derivation")`
39+
* over the SSO handshake (RFC-0007 "Option 1"). Given the same `rootEntropySource`,
40+
* `productId`, and `key`, the output is byte-for-byte identical to
41+
* {@link deriveProductEntropy} called with the corresponding `rootAccountSecret`.
42+
*
43+
* Do NOT pass a `rootEntropySource` to {@link deriveProductEntropy}: that would
44+
* re-apply layer 1 and produce a different, non-conforming result.
45+
*
46+
* @param rootEntropySource - 32-byte source derived from the root account secret
47+
* @param productId - Identifier of the calling product (arbitrary-length string)
48+
* @param key - Caller-chosen key, 1 to 32 bytes
49+
*/
50+
export function deriveProductEntropyFromSource(
51+
rootEntropySource: Uint8Array,
52+
productId: string,
53+
key: Uint8Array,
54+
): Uint8Array {
2055
if (key.length === 0 || key.length > 32) {
2156
throw new Error(`"key" must be between 1 and 32 bytes, got ${key.length}`);
2257
}
2358

24-
const textEncoder = new TextEncoder();
25-
26-
const rootEntropySource = blake2b256Keyed(rootAccountSecret, textEncoder.encode('product-entropy-derivation'));
2759
const perProductEntropy = blake2b256Keyed(rootEntropySource, blake2b256(textEncoder.encode(productId)));
2860

2961
return blake2b256Keyed(perProductEntropy, key);

packages/host-container/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ export { createContainer } from './createContainer.js';
44
export type { Container, ContainerHandlerOf, CreateContainerOptions, HostApiDebugMessageEvent } from './types.js';
55
export { onHostApiDebugMessage } from './debugBus.js';
66

7-
export { deriveProductEntropy } from './deriveEntropy.js';
7+
export { deriveProductEntropy, deriveProductEntropyFromSource } from './deriveEntropy.js';
88
export { createRateLimiter } from './rateLimiter.js';
99
export type { CreateRateLimiterConfig, RateLimiter, RateLimiterConfig, RateLimiterStrategy } from './rateLimiter.js';

packages/host-papp/__tests__/auth.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const IDENTITY_CHAT_PRIV = new Uint8Array(32).fill(0xdd);
2222
const IDENTITY_ACCT = new Uint8Array(32).fill(0xa1);
2323
const ROOT_ACCT = new Uint8Array(32).fill(0xa2);
2424
const SSO_ENC_PUB = new Uint8Array(65).fill(0x06);
25+
const ROOT_ENTROPY_SOURCE = new Uint8Array(32).fill(0x07);
2526
const PEER_STMT_ACCT_HEX = '0x' + '44'.repeat(32);
2627

2728
const makeDeviceIdentity = (): DeviceIdentityForPairing => ({
@@ -45,8 +46,9 @@ const buildSuccessStatement = (): Statement => {
4546
identityChatPrivateKey: IDENTITY_CHAT_PRIV,
4647
ssoEncPubKey: SSO_ENC_PUB,
4748
deviceEncPubKey: DEVICE_ENC_PUB,
49+
rootEntropySource: ROOT_ENTROPY_SOURCE,
4850
});
49-
// The inner body is a length-dispatched Success (226-byte v0.2.2 payload).
51+
// The inner body is a length-dispatched Success (258-byte payload with rootEntropySource).
5052
// Wrap it as the discriminated `EncryptedHandshakeResponseV2::Success` for
5153
// the envelope.
5254
const successEnvelope = new Uint8Array(inner.length + 1);
@@ -147,7 +149,10 @@ describe('createAuth', () => {
147149
expect(harness.ssoSessionRepository.add).toHaveBeenCalledOnce();
148150
expect(harness.userSecretRepository.write).toHaveBeenCalledOnce();
149151
const secretsCall = (harness.userSecretRepository.write as ReturnType<typeof vi.fn>).mock.calls[0];
150-
expect(secretsCall?.[1]).toMatchObject({ identityChatPrivateKey: IDENTITY_CHAT_PRIV });
152+
expect(secretsCall?.[1]).toMatchObject({
153+
identityChatPrivateKey: IDENTITY_CHAT_PRIV,
154+
rootEntropySource: ROOT_ENTROPY_SOURCE,
155+
});
151156
});
152157

153158
it('emits pairingStatus transitions: none -> initial -> pairing(deeplink) -> finished(session)', async () => {

packages/host-papp/__tests__/handshakeV2Codec.spec.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
HandshakeSuccessV2,
1515
HandshakeSuccessV2Legacy,
1616
HandshakeSuccessV2_v021,
17+
HandshakeSuccessV2_v022,
1718
MetadataKey,
1819
VersionedHandshakeProposal,
1920
VersionedHandshakeResponse,
@@ -24,6 +25,7 @@ import {
2425
const fixedChatPrivateKey = new Uint8Array(32).fill(0xdd);
2526
const fixedChatPublicKey = p256.getPublicKey(fixedChatPrivateKey, false);
2627
const fixedSsoEncPubKey = new Uint8Array(65).fill(0x06);
28+
const fixedRootEntropySource = new Uint8Array(32).fill(0x07);
2729

2830
const makeDevice = () => ({
2931
statementAccountId: new Uint8Array(32).fill(0xa1),
@@ -95,26 +97,53 @@ describe('VersionedHandshakeProposal', () => {
9597
});
9698
});
9799

98-
describe('HandshakeSuccessV2 (spec v0.2.2, 226 bytes)', () => {
99-
it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, ssoEncPubKey, deviceEncPubKey', () => {
100+
describe('HandshakeSuccessV2 (258 bytes, with rootEntropySource)', () => {
101+
it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, ssoEncPubKey, deviceEncPubKey, rootEntropySource', () => {
100102
const input = {
101103
identityAccountId: new Uint8Array(32).fill(0xa1),
102104
rootAccountId: new Uint8Array(32).fill(0xa2),
103105
identityChatPrivateKey: fixedChatPrivateKey,
104106
ssoEncPubKey: fixedSsoEncPubKey,
105107
deviceEncPubKey: new Uint8Array(65).fill(0x04),
108+
rootEntropySource: fixedRootEntropySource,
106109
};
107110
const decoded = HandshakeSuccessV2.dec(HandshakeSuccessV2.enc(input));
108111
expect(decoded).toEqual(input);
109112
});
110113

111-
it('encodes to exactly 226 bytes', () => {
114+
it('encodes to exactly 258 bytes', () => {
112115
const encoded = HandshakeSuccessV2.enc({
113116
identityAccountId: new Uint8Array(32).fill(0xa1),
114117
rootAccountId: new Uint8Array(32).fill(0xa2),
115118
identityChatPrivateKey: fixedChatPrivateKey,
116119
ssoEncPubKey: fixedSsoEncPubKey,
117120
deviceEncPubKey: new Uint8Array(65).fill(0x04),
121+
rootEntropySource: fixedRootEntropySource,
122+
});
123+
expect(encoded.length).toBe(258);
124+
});
125+
});
126+
127+
describe('HandshakeSuccessV2_v022 (spec v0.2.2, 226 bytes)', () => {
128+
it('round-trips identityAccountId, rootAccountId, identityChatPrivateKey, ssoEncPubKey, deviceEncPubKey', () => {
129+
const input = {
130+
identityAccountId: new Uint8Array(32).fill(0xa1),
131+
rootAccountId: new Uint8Array(32).fill(0xa2),
132+
identityChatPrivateKey: fixedChatPrivateKey,
133+
ssoEncPubKey: fixedSsoEncPubKey,
134+
deviceEncPubKey: new Uint8Array(65).fill(0x04),
135+
};
136+
const decoded = HandshakeSuccessV2_v022.dec(HandshakeSuccessV2_v022.enc(input));
137+
expect(decoded).toEqual(input);
138+
});
139+
140+
it('encodes to exactly 226 bytes', () => {
141+
const encoded = HandshakeSuccessV2_v022.enc({
142+
identityAccountId: new Uint8Array(32).fill(0xa1),
143+
rootAccountId: new Uint8Array(32).fill(0xa2),
144+
identityChatPrivateKey: fixedChatPrivateKey,
145+
ssoEncPubKey: fixedSsoEncPubKey,
146+
deviceEncPubKey: new Uint8Array(65).fill(0x04),
118147
});
119148
expect(encoded.length).toBe(226);
120149
});
@@ -170,13 +199,34 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode
170199
expect(decoded).toEqual({ tag: 'Pending', value: { tag: 'AllowanceAllocation', value: undefined } });
171200
});
172201

173-
it('decodes a 226-byte v0.2.2 Success body with ssoEncPubKey', () => {
202+
it('decodes a 258-byte Success body with rootEntropySource', () => {
174203
const body = HandshakeSuccessV2.enc({
175204
identityAccountId: new Uint8Array(32).fill(0xa1),
176205
rootAccountId: new Uint8Array(32).fill(0xa2),
177206
identityChatPrivateKey: fixedChatPrivateKey,
178207
ssoEncPubKey: fixedSsoEncPubKey,
179208
deviceEncPubKey: new Uint8Array(65).fill(0x04),
209+
rootEntropySource: fixedRootEntropySource,
210+
});
211+
const bytes = new Uint8Array(1 + body.length);
212+
bytes[0] = 0x01;
213+
bytes.set(body, 1);
214+
const decoded = decodeEncryptedHandshakeResponseV2(bytes);
215+
expect(decoded.tag).toBe('Success');
216+
if (decoded.tag !== 'Success') return;
217+
expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2));
218+
expect(decoded.value.ssoEncPubKey).toEqual(fixedSsoEncPubKey);
219+
expect(decoded.value.deviceEncPubKey).toEqual(new Uint8Array(65).fill(0x04));
220+
expect(decoded.value.rootEntropySource).toEqual(fixedRootEntropySource);
221+
});
222+
223+
it('decodes a 226-byte v0.2.2 Success body with ssoEncPubKey and surfaces rootEntropySource as null', () => {
224+
const body = HandshakeSuccessV2_v022.enc({
225+
identityAccountId: new Uint8Array(32).fill(0xa1),
226+
rootAccountId: new Uint8Array(32).fill(0xa2),
227+
identityChatPrivateKey: fixedChatPrivateKey,
228+
ssoEncPubKey: fixedSsoEncPubKey,
229+
deviceEncPubKey: new Uint8Array(65).fill(0x04),
180230
});
181231
const bytes = new Uint8Array(1 + body.length);
182232
bytes[0] = 0x01;
@@ -187,6 +237,7 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode
187237
expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2));
188238
expect(decoded.value.ssoEncPubKey).toEqual(fixedSsoEncPubKey);
189239
expect(decoded.value.deviceEncPubKey).toEqual(new Uint8Array(65).fill(0x04));
240+
expect(decoded.value.rootEntropySource).toBeNull();
190241
});
191242

192243
it('decodes a 161-byte v0.2.1 Success body with rootAccountId and surfaces ssoEncPubKey as null', () => {
@@ -205,6 +256,7 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode
205256
expect(decoded.value.rootAccountId).toEqual(new Uint8Array(32).fill(0xa2));
206257
expect(decoded.value.identityChatPrivateKey).toEqual(fixedChatPrivateKey);
207258
expect(decoded.value.ssoEncPubKey).toBeNull();
259+
expect(decoded.value.rootEntropySource).toBeNull();
208260
});
209261

210262
it('decodes a 129-byte v0.2 Success body and surfaces rootAccountId as null', () => {
@@ -221,13 +273,14 @@ describe('decodeEncryptedHandshakeResponseV2 (length-dispatched plaintext decode
221273
if (decoded.tag !== 'Success') return;
222274
expect(decoded.value.rootAccountId).toBeNull();
223275
expect(decoded.value.ssoEncPubKey).toBeNull();
276+
expect(decoded.value.rootEntropySource).toBeNull();
224277
expect(decoded.value.identityAccountId).toEqual(new Uint8Array(32).fill(0xa1));
225278
});
226279

227280
it('rejects a Success body of unknown length', () => {
228281
const bytes = new Uint8Array(50);
229282
bytes[0] = 0x01;
230-
expect(() => decodeEncryptedHandshakeResponseV2(bytes)).toThrow(/not in \{129, 161, 226\}/);
283+
expect(() => decodeEncryptedHandshakeResponseV2(bytes)).toThrow(/not in \{129, 161, 226, 258\}/);
231284
});
232285

233286
it('decodes Failed with a UTF-8 reason string', () => {
@@ -257,7 +310,7 @@ describe('EncryptedHandshakeResponseV2 (native scale-ts Enum, used for encode)',
257310
expect(encoded).toEqual(new Uint8Array([0x00, 0x00]));
258311
});
259312

260-
it('round-trips Success on the v0.2.2 wire format', () => {
313+
it('round-trips Success with rootEntropySource', () => {
261314
const success = {
262315
tag: 'Success' as const,
263316
value: {
@@ -266,10 +319,11 @@ describe('EncryptedHandshakeResponseV2 (native scale-ts Enum, used for encode)',
266319
identityChatPrivateKey: fixedChatPrivateKey,
267320
ssoEncPubKey: fixedSsoEncPubKey,
268321
deviceEncPubKey: new Uint8Array(65).fill(0x04),
322+
rootEntropySource: fixedRootEntropySource,
269323
},
270324
};
271325
const encoded = EncryptedHandshakeResponseV2.enc(success);
272-
expect(encoded.length).toBe(1 + 226);
326+
expect(encoded.length).toBe(1 + 258);
273327
expect(encoded[0]).toBe(0x01);
274328
expect(EncryptedHandshakeResponseV2.dec(encoded)).toEqual(success);
275329
});

packages/host-papp/__tests__/handshakeV2Service.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ describe('startPairingV2', () => {
130130
identityChatPrivateKey: new Uint8Array(32).fill(0xdd),
131131
ssoEncPubKey: new Uint8Array(65).fill(0x06),
132132
deviceEncPubKey: new Uint8Array(65).fill(0x04),
133+
rootEntropySource: new Uint8Array(32).fill(0x07),
133134
},
134135
});
135136
store.emit([buildStatement(device, successBytes)]);

0 commit comments

Comments
 (0)