Skip to content

Commit

Permalink
Merge pull request #15 from deso-protocol/feat/get-derived-key-with-seed
Browse files Browse the repository at this point in the history
[draft] Feature: new loginWithAutoDerive method to generate derived keys by passing seed hex
  • Loading branch information
jackson-dean authored Jun 23, 2023
2 parents 94ea282 + 9e5bb58 commit 5b588d0
Show file tree
Hide file tree
Showing 7 changed files with 351 additions and 24 deletions.
15 changes: 14 additions & 1 deletion src/identity/crypto-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ export const bufToUvarint64 = (buffer: Uint8Array): [number, Uint8Array] => {
}
};

export const uint64ToBufBigEndian = (uint: number) => {
const result = [];
while (BigInt(uint) >= BigInt(0xff)) {
result.push(Number(BigInt(uint) & BigInt(0xff)));
uint = Number(BigInt(uint) >> BigInt(8));
}
result.push(Number(BigInt(uint) | BigInt(0)));
while (result.length < 8) {
result.push(0);
}
return new Uint8Array(result.reverse());
};

interface Base58CheckOptions {
network: Network;
}
Expand Down Expand Up @@ -130,7 +143,7 @@ export interface SignOptions {
isDerivedKey: boolean;
}

const sign = (msgHashHex: string, privateKey: Uint8Array) => {
export const sign = (msgHashHex: string, privateKey: Uint8Array) => {
return ecSign(msgHashHex, privateKey, {
// For details about the signing options see: https://github.com/paulmillr/noble-secp256k1#signmsghash-privatekey
canonical: true,
Expand Down
100 changes: 100 additions & 0 deletions src/identity/derived-key-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { utils as ecUtils } from '@noble/secp256k1';
import { TransactionSpendingLimitResponse } from '../backend-types/index.js';
import { api, getAppState } from '../data/index.js';
import {
deriveAccessGroupKeyPair,
getSignedJWT,
publicKeyToBase58Check,
sha256X2,
sign,
uint64ToBufBigEndian,
} from './crypto-utils.js';
import { KeyPair, Network } from './types.js';

export async function generateDerivedKeyPayload(
ownerKeys: KeyPair,
derivedKeys: KeyPair,
transactionSpendingLimitObj: TransactionSpendingLimitResponse,
numDaysBeforeExpiration: number,
network: Network,
{ defaultMessagingGroupName } = {
defaultMessagingGroupName: 'default-key',
}
) {
const { BlockHeight } = await getAppState();

// days * (24 hours / day) * (60 minutes / hour) * (1 block / 5 minutes) = blocks
const expirationBlockHeight =
BlockHeight + (numDaysBeforeExpiration * 24 * 60) / 5;
const ownerPublicKeyBase58 = publicKeyToBase58Check(ownerKeys.public, {
network,
});
const derivedPublicKeyBase58 = publicKeyToBase58Check(derivedKeys.public, {
network,
});
const { TransactionSpendingLimitHex } = await api.post(
'/api/v0/get-access-bytes',
{
DerivedPublicKeyBase58Check: derivedPublicKeyBase58,
ExpirationBlock: expirationBlockHeight,
TransactionSpendingLimit: transactionSpendingLimitObj,
}
);
const transactionSpendingLimitBytes = TransactionSpendingLimitHex
? ecUtils.hexToBytes(TransactionSpendingLimitHex)
: [];
const accessBytes = new Uint8Array([
...derivedKeys.public,
...uint64ToBufBigEndian(expirationBlockHeight),
...transactionSpendingLimitBytes,
]);
const accessHashHex = ecUtils.bytesToHex(sha256X2(accessBytes));
const [accessSignature] = await sign(accessHashHex, ownerKeys.private);
const messagingKey = deriveAccessGroupKeyPair(
ownerKeys.seedHex,
defaultMessagingGroupName
);
const messagingPublicKeyBase58Check = publicKeyToBase58Check(
messagingKey.public,
{ network }
);
const messagingKeyHashHex = ecUtils.bytesToHex(
sha256X2(
new Uint8Array([
...messagingKey.public,
...new TextEncoder().encode(defaultMessagingGroupName),
])
)
);
const [messagingKeySignature] = await sign(
messagingKeyHashHex,
ownerKeys.private
);

const [jwt, derivedJwt] = await Promise.all([
getSignedJWT(ownerKeys.seedHex, 'ES256', {}),
getSignedJWT(ownerKeys.seedHex, 'ES256', {
derivedPublicKeyBase58Check: derivedPublicKeyBase58,
}),
]);

return {
derivedSeedHex: derivedKeys.seedHex,
derivedPublicKeyBase58Check: derivedPublicKeyBase58,
publicKeyBase58Check: ownerPublicKeyBase58,
btcDepositAddress: 'Not implemented yet',
ethDepositAddress: 'Not implemented yet',
expirationBlock: expirationBlockHeight,
network,
accessSignature: ecUtils.bytesToHex(accessSignature),
jwt,
derivedJwt,
messagingPublicKeyBase58Check,
messagingPrivateKey: messagingKey.seedHex,
messagingKeyName: defaultMessagingGroupName,
messagingKeySignature: ecUtils.bytesToHex(messagingKeySignature),
transactionSpendingLimitHex: TransactionSpendingLimitHex,
signedUp: false,
publicKeyAdded: ownerPublicKeyBase58,
};
}
133 changes: 132 additions & 1 deletion src/identity/identity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { getAPIFake, getWindowFake } from '../test-utils.js';
import { APIError } from './api.js';
import { DEFAULT_IDENTITY_URI, LOCAL_STORAGE_KEYS } from './constants.js';
import {
bs58PublicKeyToBytes,
bs58PublicKeyToCompressedBytes,
isValidBS58PublicKey,
keygen,
Expand Down Expand Up @@ -1004,6 +1003,138 @@ describe('identity', () => {
expect(snapshot.currentUser?.publicKey).toEqual(pubKey2);
});
});
describe('loginWithAutoDerive()', () => {
it('it stores the expected derive data when generating a local derived key payload', async () => {
const expectedExpirationBlock = 1294652;
const expectedDerivePayload = {
derivedPublicKeyBase58Check:
'BC1YLhKdgXgrZ1XkCzbmP6T9bumth2DgPwNjMksCAXe5kGU9LnxQtsX',
publicKeyBase58Check:
'BC1YLgamBUfZxYwKp7VmseJEXBxFdWNZJ1KW8q1YphDJcYiCMKZ9fFC',
btcDepositAddress: 'Not implemented yet',
ethDepositAddress: 'Not implemented yet',
expirationBlock: expectedExpirationBlock,
accessSignature:
'3045022018e653cc79575ad947dd48234461a42f94813b94f6876505d3d10d9040af6fc4022100bbe5b07d98b819706e6bb12949d5cd1067da8bfa8f7abd0b8e150190b5f303ae',
messagingPublicKeyBase58Check:
'BC1YLhaLw1va7HQjrHjPjChbhpSVREE3EYpmxSDzTLwFVQZ5NZfSX2B',
messagingPrivateKey:
'7117ca2541c6e2417b05e6e5762a512e1932cb1d59dedcf7e37795114877161b',
messagingKeyName: 'default-key',
messagingKeySignature:
'3045022019bf391d1f5fd9c0c83000b6da579a954c8e1516e9985d62a96351442e952c37022100c1d59dbf1a2d8a547b44f2b11507aaedfa9602855a4a2d19849f22ad97c8d9f7',
// TODO: get a deterministic value for this from a real derive call to the identity window using a known tx limit map.
transactionSpendingLimitHex: '',
signedUp: true,
};
const ownerSeedHex =
'9bd433466f03e7f72708975b8759e357f59089e621ea353a7a986d18e5904f1f';
const derivedSeedHex =
'eb3e8348f1f3225f83abd5d2d68b7a12b4b07b843bfec3776e5fd7f25e069469';
const identityConfig = {
storageProvider: windowFake.localStorage,
spendingLimitOptions: {
GlobalDESOLimit: 12345,
TransactionCountLimitMap: {
AUTHORIZE_DERIVED_KEY: 1,
},
},
};

// get a clean identity instance
identity = new Identity<Storage>(windowFake, apiFake);
identity.configure(identityConfig);

globalThis.fetch = jest
.fn()
.mockImplementation((url, options) => {
if (
options?.method === 'POST' &&
url.endsWith('/api/v0/get-app-state')
) {
return Promise.resolve({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
// BlockHeight captured on 6/22/2023 10:27AM PST
BlockHeight: 243452,
})
),
});
}

if (
options?.method === 'POST' &&
url.endsWith('/api/v0/get-access-bytes')
) {
// Assert we're sending the expected POST body
expect(JSON.parse(options.body)).toEqual({
ExpirationBlock: expectedExpirationBlock,
TransactionSpendingLimit: identityConfig.spendingLimitOptions,
DerivedPublicKeyBase58Check: JSON.parse(
windowFake.localStorage.getItem(
LOCAL_STORAGE_KEYS.loginKeyPair
) ?? '{}'
).publicKey,
});

return Promise.resolve({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
TransactionSpendingLimitHex:
expectedDerivePayload.transactionSpendingLimitHex,
})
),
});
}

return Promise.reject(
new Error(
`fetch called with unmocked url: ${JSON.stringify(
options
)} ${url}`
)
);
})
.mockName('fetch');

await identity.loginWithAutoDerive(ownerSeedHex, {
derivedSeedHex: derivedSeedHex,
});

const { currentUser } = await identity.snapshot();
const derivedKeyInfo = currentUser?.primaryDerivedKey;

expect(currentUser?.publicKey).toEqual(
expectedDerivePayload.publicKeyBase58Check
);
expect(derivedKeyInfo?.derivedPublicKeyBase58Check).toEqual(
expectedDerivePayload.derivedPublicKeyBase58Check
);
expect(derivedKeyInfo?.expirationBlock).toEqual(expectedExpirationBlock);
expect(derivedKeyInfo?.transactionSpendingLimitHex).toEqual(
expectedDerivePayload.transactionSpendingLimitHex
);
expect(derivedKeyInfo?.messagingKeyName).toEqual(
expectedDerivePayload.messagingKeyName
);
expect(derivedKeyInfo?.messagingPublicKeyBase58Check).toEqual(
expectedDerivePayload.messagingPublicKeyBase58Check
);
expect(derivedKeyInfo?.messagingPrivateKey).toEqual(
expectedDerivePayload.messagingPrivateKey
);
expect(derivedKeyInfo?.derivedSeedHex).toEqual(derivedSeedHex);

// NOTE: signatures are non-deterministic, so unfortunately we can't test
// them for strict equality But we can minimally test that they exist.
expect(derivedKeyInfo?.messagingKeySignature).toBeTruthy();
expect(derivedKeyInfo?.accessSignature).toBeTruthy();
});
});
describe('.subscribe()', () => {
it.todo('it notifies the caller of the correct events');
});
Expand Down
Loading

0 comments on commit 5b588d0

Please sign in to comment.