Construction derive API locally in JS

I am trying to have the functionality provided by the Derive API locally in javascript. I have written a script and its working but its not giving the correct account_identifier. Below is the script, can someone help me here
the correct principal is dgsc3-x3mgj-35squ-7s7wl-ww65m-o55e2-xikdq-47ri3-27upl-b52sv-kqe
the correct account_id is 5b8ba540ea14bcf4f71a03cecbca0f3c8f15959beaad48a704470f1bb1f79ae7

import elliptic from 'elliptic';
import crypto from 'crypto';
import crc32 from 'crc-32';
import { Principal } from '@dfinity/principal';

const EC = new elliptic.ec('secp256k1');

const PrincipalIdClass = {
    SelfAuthenticating: 0x01,
    Opaque: 0x02,
    Derived: 0x03,
    Anonymous: 0x04,
};

class PrincipalId {
    constructor(bytes) {
        this.bytes = bytes;
    }

    static newSelfAuthenticating(publicKey) {
        const sha224 = crypto.createHash('sha224');
        sha224.update(publicKey);
        const hash = sha224.digest();
        const id = Buffer.concat([hash, Buffer.from([PrincipalIdClass.SelfAuthenticating])]);
        return new PrincipalId(id);
    }
}

function principalIdFromPublicKey(pkHex) {
    const publicKeyBuffer = Buffer.from(pkHex, 'hex');
    
    try {
        const key = EC.keyFromPublic(publicKeyBuffer);
        const sec1PublicKey = key.getPublic(true, 'hex');
        const principalId = PrincipalId.newSelfAuthenticating(Buffer.from(sec1PublicKey, 'hex'));

        const principal = Principal.fromUint8Array(principalId.bytes);
        return { principalId, principal };
    } catch (err) {
        throw new Error(`Error processing public key: ${err.message}`);
    }
}

function generateAccountIdentifier(principal, subAccount = new Uint8Array(32)) {
    const ACCOUNT_DOMAIN_SEPARATOR = new Uint8Array([0x0A, ...Buffer.from('account-id')]);
    const principalBytes = principal.toUint8Array();
    const combinedBytes = new Uint8Array(
        ACCOUNT_DOMAIN_SEPARATOR.length + principalBytes.length + subAccount.length
    );
    combinedBytes.set(ACCOUNT_DOMAIN_SEPARATOR, 0);
    combinedBytes.set(principalBytes, ACCOUNT_DOMAIN_SEPARATOR.length);
    combinedBytes.set(subAccount, ACCOUNT_DOMAIN_SEPARATOR.length + principalBytes.length);
    const sha224Hash = crypto.createHash('sha224').update(combinedBytes).digest();
    const checksum = Buffer.alloc(4);
    checksum.writeUInt32BE(crc32.buf(sha224Hash) >>> 0, 0);
    const accountIdBytes = Buffer.concat([checksum, sha224Hash]);
    return accountIdBytes.toString('hex');
}

const pkHex = '047a83e378053f87b49aeae53b3ed274c8b2ffbe59d9a51e3c4d850ca8ac1684f7131b778317c0db04de661c7d08321d60c0507868af41fe3150d21b3c6c757367'; // Replace with actual hex public key
try {
    const { principalId, principal } = principalIdFromPublicKey(pkHex);
    console.log('Generated Principal ID:', principalId.bytes.toString('hex'));
    console.log('Generated Principal:', principal);
    
    const subAccount = new Uint8Array(32);
    const accountId = generateAccountIdentifier(principal, subAccount);
    console.log('Generated Account Identifier:', accountId);
} catch (error) {
    console.error('Error:', error.message);
}
1 Like

Any specific reason you’re not using @dfinity/ledger-icp?

1 Like

@Severin there is no specific reason for not using @dfinity/ledger-icp, I just tried to convert the rosetta rust code to JS
We want to use the rosetta construction API like combine and submit but not the derive API, therefore we are trying to replicate the derive API locally
if you have any references/suggestions do let me know

1 Like

Then I would suggest you look at the @dfinity/ledger-icp implementation to see how it works there

1 Like

@Severin I had a look at the @dfinity/ledger-icp and I only find a method for converting principal to account_identifier but not public_key to account_identifier

1 Like

Ah sorry, I overlooked that you want to start with a public key, not a principal. If @dfinity/principal does not help, maybe the relevant section in the spec could help?

My first guess is that you messed up the class bytes. Per the spec SelfAuthenticating is 0x02, not 0x01

1 Like

Someone internal just tried go from a PEM file to a principal. Here’s some snippets from that conversation that could be useful:

To my knowledge the public key has to be DER encoded before it is hashed (sha224). I suppose your openssl command probably doesn’t output DER-encoding.

Try openssl with -outform DER option

This is how I generated the files (I’m using this with the test identity created with dfx nns installbut it should work with any test identity)

$ dfx identity export nns-test > nns-test-private.pem
$ openssl ec -in nns-test-private.pem -pubout -out nns-test-public.pem

In any case, you are right. This worked:

#!/bin/bash


function textual_encode() {
  ( echo -n "$1" | xxd -r -p | /usr/bin/crc32 /dev/stdin; echo -n "$1" ) |
  xxd -r -p | base32 | tr A-Z a-z |
  tr -d = | fold -w5 | paste -sd'-' -
}

UNCOMPRESSED_DER_SHA224=$(openssl ec -pubin -in nns-test-public.pem -outform DER | sha224)
PRINCIPAL_ID_HEX="${UNCOMPRESSED_DER_SHA224}02"

textual_encode ${PRINCIPAL_ID_HEX}

@Severin after taking some reference from agent-js/packages/agent/src/der.ts at ce618ebb43173ffdc3f9be66dbf806f40a9519be · dfinity/agent-js · GitHub
I was able to get the correct principal and account_identifier from the below script

import crypto from 'crypto';
import crc32 from 'crc-32';
import { Principal } from '@dfinity/principal';

const EC = new elliptic.ec('secp256k1');

const PrincipalIdClass = {
    Opaque: 0x01,
    SelfAuthenticating: 0x02,
    Derived: 0x03,
    Anonymous: 0x04,
};

class PrincipalId {
    constructor(bytes) {
        this.bytes = bytes;
    }

    static newSelfAuthenticating(publicKey) {
        const sha224 = crypto.createHash('sha224');
        sha224.update(publicKey);
        const hash = sha224.digest();
        const id = Buffer.concat([hash, Buffer.from([PrincipalIdClass.SelfAuthenticating])]);
        return new PrincipalId(id);
    }
}

function principalIdFromPublicKey(pkHex) {
    const publicKeyBuffer = Buffer.from(pkHex, 'hex');

    try {
        const key = EC.keyFromPublic(publicKeyBuffer);
        const sec1PublicKey = key.getPublic(false, 'hex');
        console.log('sec1PublicKey:', sec1PublicKey);
        const pk_der = wrapDER(Buffer.from(sec1PublicKey, 'hex'), SECP256K1_OID);
        console.log('der:', pk_der.toString('hex'));
        const principalId = PrincipalId.newSelfAuthenticating(Buffer.from(pk_der, 'hex'));

        const principal = Principal.fromUint8Array(principalId.bytes);
        return { principalId, principal };
    } catch (err) {
        throw new Error(`Error processing public key: ${err.message}`);
    }
}

const SECP256K1_OID = Uint8Array.from([
    ...[0x30, 0x10],
    ...[0x06, 0x07],
    ...[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01],
    ...[0x06, 0x05],
    ...[0x2b, 0x81, 0x04, 0x00, 0x0a],
]);

const encodeLenBytes = (len) => {
    if (len <= 0x7f) {
        return 1;
    } else if (len <= 0xff) {
        return 2;
    } else if (len <= 0xffff) {
        return 3;
    } else if (len <= 0xffffff) {
        return 4;
    } else {
        throw new Error('Length too long (> 4 bytes)');
    }
};

const encodeLen = (buf, offset, len) => {
    if (len <= 0x7f) {
        buf[offset] = len;
        return 1;
    } else if (len <= 0xff) {
        buf[offset] = 0x81;
        buf[offset + 1] = len;
        return 2;
    } else if (len <= 0xffff) {
        buf[offset] = 0x82;
        buf[offset + 1] = len >> 8;
        buf[offset + 2] = len;
        return 3;
    } else if (len <= 0xffffff) {
        buf[offset] = 0x83;
        buf[offset + 1] = len >> 16;
        buf[offset + 2] = len >> 8;
        buf[offset + 3] = len;
        return 4;
    } else {
        throw new Error('Length too long (> 4 bytes)');
    }
};

function wrapDER(payload, oid) {
    const bitStringHeaderLength = 2 + encodeLenBytes(payload.byteLength + 1);
    const len = oid.byteLength + bitStringHeaderLength + payload.byteLength;
    let offset = 0;
    const buf = new Uint8Array(1 + encodeLenBytes(len) + len);
    buf[offset++] = 0x30;
    offset += encodeLen(buf, offset, len);
    buf.set(oid, offset);
    offset += oid.byteLength;
    buf[offset++] = 0x03;
    offset += encodeLen(buf, offset, payload.byteLength + 1);
    buf[offset++] = 0x00;
    buf.set(new Uint8Array(payload), offset);
    return buf;
}

function generateAccountIdentifier(principal, subAccount = new Uint8Array(32)) {
    const ACCOUNT_DOMAIN_SEPARATOR = new Uint8Array([0x0A, ...Buffer.from('account-id')]);
    const principalBytes = principal.toUint8Array();
    const combinedBytes = new Uint8Array(
        ACCOUNT_DOMAIN_SEPARATOR.length + principalBytes.length + subAccount.length
    );
    combinedBytes.set(ACCOUNT_DOMAIN_SEPARATOR, 0);
    combinedBytes.set(principalBytes, ACCOUNT_DOMAIN_SEPARATOR.length);
    combinedBytes.set(subAccount, ACCOUNT_DOMAIN_SEPARATOR.length + principalBytes.length);
    const sha224Hash = crypto.createHash('sha224').update(combinedBytes).digest();
    const checksum = Buffer.alloc(4);
    checksum.writeUInt32BE(crc32.buf(sha224Hash) >>> 0, 0);
    const accountIdBytes = Buffer.concat([checksum, sha224Hash]);
    return accountIdBytes.toString('hex');
}

const pkHex = '0431eea4ca769823ce19b0386177a856322de426c603f0e7f3c552e1a65903d0b5dcd1f39704c0e56afd194525b04a79e27f72cdde8a9ce410685ca4e934dd5bcd';
try {
    const { principalId, principal } = principalIdFromPublicKey(pkHex);
    console.log('Generated Principal ID:', principalId.bytes.toString('hex'));
    console.log('Generated Principal:', principal.toString());

    const subAccount = new Uint8Array(32);
    const accountId = generateAccountIdentifier(principal, subAccount);
    console.log('Generated Account Identifier:', accountId);
} catch (error) {
    console.error('Error:', error.message);
}
1 Like