Skip to content

Instantly share code, notes, and snippets.

@magnusnordlander
Last active July 19, 2023 10:50
Show Gist options
  • Save magnusnordlander/c8682fda2e15b813e5308624877cce59 to your computer and use it in GitHub Desktop.
Save magnusnordlander/c8682fda2e15b813e5308624877cce59 to your computer and use it in GitHub Desktop.
Vanmoof SA5 Certificate exporter

Requirements

  • node
  • @noble/ed25519
  • axios

How to use

  1. Update the last line of keypair-generator.js to use your Vanmoof account details.
  2. Run node keypair-generator.js > bikecredentials.txt
  3. Verify that bikecredentials.txt contains a private key ("Privkey"), and both a certificate and an ECU Serial for each SA5 bike in your account.
  4. Verify that the certificate(s) in bikecredentials.txt has an expiry 10 years in the future (previously this was 7 days)
  5. Save bikecredentials.txt somewhere safe. These files contain everything needed to communicate with your bike(s).
/*
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
*/
const axios = require('axios');
const getCert = (async (email, password) => {
const ed = await import("@noble/ed25519");
const privKey = ed.utils.randomPrivateKey(); // Secure random private key
const pubKey = await ed.getPublicKeyAsync(privKey);
console.log("Privkey = " + Buffer.from(privKey).toString('base64'));
console.log("Pubkey = " + Buffer.from(pubKey).toString('base64'));
console.log("");
try {
const authResponse = await axios.post('https://my.vanmoof.com/api/v8/authenticate', null, {
headers: {
'Authorization': "Basic "+Buffer.from(email+':'+password).toString('base64'),
'Api-Key': "fcb38d47-f14b-30cf-843b-26283f6a5819",
'User-Agent': "VanMoof/20 CFNetwork/1404.0.5 Darwin/22.3.0",
}
});
const authToken = authResponse.data.token;
const appTokenResponse = await axios.get('https://api.vanmoof-api.com/v8/getApplicationToken', {
headers: {
'Authorization': "Bearer "+authToken,
'Api-Key': "fcb38d47-f14b-30cf-843b-26283f6a5819",
'User-Agent': "VanMoof/20 CFNetwork/1404.0.5 Darwin/22.3.0",
}
});
const appToken = appTokenResponse.data.token;
const bikeDataResponse = await axios.get('https://my.vanmoof.com/api/v8/getCustomerData?includeBikeDetails', {
headers: {
'Authorization': "Bearer "+authToken,
'Api-Key': "fcb38d47-f14b-30cf-843b-26283f6a5819",
'User-Agent': "VanMoof/20 CFNetwork/1404.0.5 Darwin/22.3.0",
}
});
for (const bikeIdx in bikeDataResponse.data.data.bikes) {
const bike = bikeDataResponse.data.data.bikes[bikeIdx];
console.log("Bike "+bike.name);
console.log("Bike ID: "+ bike.bikeId);
console.log("Frame number: "+ bike.frameNumber);
console.log("Frame serial: "+ bike.frameSerial);
if (bike.bleProfile === 'ELECTRIFIED_2022') {
console.log("Bike is an SA5");
console.log("ECU Serial: "+ bike.mainEcuSerial);
const certificateResponse = await axios.post('https://bikeapi.production.vanmoof.cloud/bikes/'+bike.bikeId+'/create_certificate', {
'public_key': Buffer.from(pubKey).toString('base64'),
}, {
headers: {
'Authorization': "Bearer "+appToken,
'User-Agent': "VanMoof/20 CFNetwork/1404.0.5 Darwin/22.3.0",
}
});
console.log("Certificate below:")
console.log("-----------");
console.log(certificateResponse.data);
console.log("-----------");
} else {
console.log("Not an SA5.")
}
}
console.log("");
console.log("");
} catch (error) {
console.log(error);
}
});
getCert("YOUR_VANMOOF_EMAIL", 'YOUR_VANMOOF_PASSWORD');
@delfuego
Copy link

@bmensink Looks like that specific error is coming from @noble/ed25519, and that that module only supports Node 19+:

https://github.com/paulmillr/noble-ed25519/blob/a84f03e2f6b24a01921b67f4abe705a082b94c2d/index.js#L306

Baffling, yep, since Node 17.4+ supports the thing they're complaining is missing.

@ArthurCowboy
Copy link

@magnusnordlander Thx for your snippet! Do you know what is this certificate exactly? is it the equivalent of the encryption_key for the S3/X3?

@Daveloose
Copy link

Daveloose commented Jul 14, 2023

Add a package.json file with the following content:

{ "dependencies": { "@noble/ed25519": "^2.0.0", "axios": "^1.4.0" } }

Then do npm install, use it with node v20.4.0 and the keypair-generator should work.

@magnusnordlander
Copy link
Author

@magnusnordlander Thx for your snippet! Do you know what is this certificate exactly? is it the equivalent of the encryption_key for the S3/X3?

It is the equivalent in purpose, but the protocol used by SA5 is completely different from the SX3. For the moment, just save the data. If Vanmoof (or whomever acquires Vanmoof) stops providing a working app, you will be able to use it with third party apps.

@nivvle
Copy link

nivvle commented Jul 15, 2023

I'm getting the error below despite having installed @noble/ed25519.. Any suggestions?

elvin@MacBook-Pro-2 A5 exporter % node keypair-generator.js > bikecredentials.txt
node:internal/errors:490
    ErrorCaptureStackTrace(err);
    ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@noble/ed25519' imported from /Users/elvin/Downloads/A5 exporter/keypair-generator.js
    at new NodeError (node:internal/errors:399:5)
    at packageResolve (node:internal/modules/esm/resolve:889:9)
    at moduleResolve (node:internal/modules/esm/resolve:938:20)
    at defaultResolve (node:internal/modules/esm/resolve:1153:11)
    at nextResolve (node:internal/modules/esm/loader:163:28)
    at ESMLoader.resolve (node:internal/modules/esm/loader:838:30)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:424:18)
    at ESMLoader.import (node:internal/modules/esm/loader:525:22)
    at importModuleDynamically (node:internal/modules/cjs/loader:1188:29)
    at importModuleDynamicallyWrapper (node:internal/vm/module:429:21) {
  code: 'ERR_MODULE_NOT_FOUND'
}

Node.js v20.4.0

> 

@nivvle
Copy link

nivvle commented Jul 15, 2023

Add a package.json file with the following content:

{ "dependencies": { "@noble/ed25519": "^2.0.0", "axios": "^1.4.0" } }

Then do npm install, use it with node v20.4.0 and the keypair-generator should work.

Do you mean putting a new json file called package.json in the directory of the downloaded keypair-generator? And then Install what exactly? Sorry noob here :)

@Daveloose
Copy link

Yes, create a new json file called package.json in the same directory, navigate to the directory in Terminal or command line. Enter the command ‘npm install’ (you should have the latest node version installed locally) and then follow the instructions above.

@nivvle
Copy link

nivvle commented Jul 15, 2023

Yes, create a new json file called package.json in the same directory, navigate to the directory in Terminal or command line. Enter the command ‘npm install’ (you should have the latest node version installed locally) and then follow the instructions above.

Got it, worked! The weird thing is that there was already a package.json file with very similar contents (see below) but perhaps the brackets made the difference..

{
  "dependencies": {
    "axios": "^1.4.0",
    "noble-ed25519": "^2.0.0"
  }
}

Anyway, so now the 228-character long certificate hash is what saved the day (our bikes) and similar to what the Bikey app extracts?

@magnusnordlander
Copy link
Author

Not just the certificate. You need the private key, the certificate and preferably the ECU serial. It’s all in the file saved following the instructions though.

@Power2All
Copy link

Could it be that it isn't working anymore ?
There is no return data.

@magnusnordlander
Copy link
Author

Could it be that it isn't working anymore ?
There is no return data.

The script still works for me

@Power2All
Copy link

Could it be that it isn't working anymore ?
There is no return data.

The script still works for me

Yah it works, but it seems you need to activate bikes for the API ?
Did a console log on the returned data, and the bike array was empty.

@WNeuteboom
Copy link

Could it be that it isn't working anymore ?
There is no return data.

The script still works for me

Yah it works, but it seems you need to activate bikes for the API ? Did a console log on the returned data, and the bike array was empty.

Just tried it, works great! Thanks for the work

@magnusnordlander
Copy link
Author

@Power2All I’m not sure I understand? But yes, your bike does need to be added to your Vanmoof account. If you can use the standard Vanmoof app to access your bike, you should be able to use this script (provided that you are logged in to the same Vanmoof account).

@Power2All
Copy link

@magnusnordlander
Strange, I'll ask the guy I tried to help, and see if it is still there.
It does show all the information back (array) of the user data.

@Power2All
Copy link

Found a possible issue.
The S5 bikes are not popping up.
That's the issue, so it works only for S3 bikes.
Is anybody looking into adding S5 bike support ?

@nivvle
Copy link

nivvle commented Jul 18, 2023

Found a possible issue.
The S5 bikes are not popping up.
That's the issue, so it works only for S3 bikes.
Is anybody looking into adding S5 bike support ?

With my A5 it worked without a problem..

@Power2All
Copy link

Power2All commented Jul 18, 2023

@nivvle Only difference I see, is that this script is made for the "A" bikes, So Ax, SAx, but not the S bikes.
I get this in the return body to their API, with some censoring of data:

  data: {
    data: {
      uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
      name: 'XX',
      email: 'xxxx@xxxx',
      confirmed: true,
      privacyPolicyAccepted: true,
      phone: 'xxxxxxxxxxxx',
      country: 'NL',
      bikes: [],
      hasPendingBikeSharingInvitations: false,
      links: [Object],
      bikeDetails: []
    },
    hash: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
  }

@WNeuteboom
Copy link

@nivvle Only difference I see, is that this script is made for the "A" bikes, So Ax, SAx, but not the S bikes. I get this in the return body to their API, with some censoring of data:

  data: {
    data: {
      uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
      name: 'XX',
      email: 'xxxx@xxxx',
      confirmed: true,
      privacyPolicyAccepted: true,
      phone: 'xxxxxxxxxxxx',
      country: 'NL',
      bikes: [],
      hasPendingBikeSharingInvitations: false,
      links: [Object],
      bikeDetails: []
    },
    hash: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
  }

It works fine for 1 s5 bike:
Bike VanMoof
Bike ID: ###
Frame number: ###
Frame serial: ###
Bike is an SA5
ECU Serial: ###
Certificate below:

@KurgerBing281
Copy link

@magnusnordlander Thanks for your work!
Finally I got my certificate today.
Expiration date 10 years in the future ✔️.

Now that VanMoof is officially declared bankrupt today, it felt extra reassuring to secure my S5 certificate.

Being a nodejs-noob, I failed to get the script to work the past week (working on Ubuntu):

  • Like others, repeatedly got the error message about the supposedly missing crypto.getRandomValues().
  • No success after editing my package.json.
  • Installing node and node-axios

This morning, a series of magical actions eventually helped:

  • several failed attempts at installing Node v20, using a setup script (yes, I was that desperate 🙁 )
  • removing nodejs
  • reinstalling nodejs, without installing node-axios
  • installing @noble/ed25519 using npm install @noble/ed25519
  • installing axios using npm install axios

Perhaps using npm for installing axios turned out to be the thing that made it work?

Other attempts using curl/bash

Your script was also very helpful in using raw curl calls.
Basically the same as @MaksimKravchuk's version, using curl which should be generally available.
Then again, I resort to jq for parsing the json.
If not available, you could copy the required info from the raw json for example.

I could get as far as step 3, but repeatedly failed at step 4.
I suspect my ed25519 public key didn't work, possibly some failed assumption at the URL data syntax or something else.

For others who may want to try:

# Variables:
apikey="fcb38d47-f14b-30cf-843b-26283f6a5819"
userpass=$(echo -n "YOUR_VANMOOF_USERNAME:YOUR_VANMOOF_PASSWORD" | base64 -w 0)
useragent="VanMoof/20 CFNetwork/1404.0.5 Darwin/22.3.0"
public_key="..." # your public ed25519 key

# Step 1: get the authentication token for the next steps
auth_token=$(curl --request POST --url "https://my.vanmoof.com/api/v8/authenticate" --header "Api-Key: ${apikey}" --header "Authorization: Basic ${userpass}" --header "User-Agent: ${useragent}" | jq -rMc '.token')

# Step 2: get the application token for the final step
app_token=$(curl --request GET --url "https://api.vanmoof-api.com/v8/getApplicationToken" --header "Api-Key: ${apikey}" --header "Authorization: Bearer ${auth_token}" --header "User-Agent: ${useragent}" | jq -rMc '.token')

# Step 3: get the bike details, saving it in 'bikedetails.json', to retrieve the bike ID(s)
curl --request GET --url "https://my.vanmoof.com/api/v8/getCustomerData?includeBikeDetails" --header "Api-Key: ${apikey}" --header "Authorization: Bearer ${auth_token}" --header "User-Agent: ${useragent}" > bikedetails.json

bike_id=$(jq -rMc '.data.bikeDetails[0].id' < bikedetails.json) # index '0': assuming you own 1 bike, or that the first bike returned is your S5 or A5.

The final step should then probably be something like:

# Step 4: (failed) attempt at creating a certificate
curl --request POST --url "https://bikeapi.production.vanmoof.cloud/bikes/${bike_id}/create_certificate" --data-urlencode "public_key=${public_key}" --header "Authorization: Bearer ${app_token}" --header "User-Agent: ${useragent}" > certificate.json

Note: this requires jq, for parsing json.
Alternatively, you could parse the json using python for example (or just copy/paste):

# assuming you've saved the json from step 1 or 2 in a file called 'token.js':
cat token.js | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data["token"])'

Obviously, adresssing @NeilBetham's concerns, plaintext credentials are even less safe than their base64-encoded versions.
You should find other ways yourself to secure your data.

Off-topic: finally created a github account specifically for this :-)

@Rhywden
Copy link

Rhywden commented Jul 18, 2023

Using those Curls, maybe I'll try to create a Postman workflow for those unwilling to engage the CLI.

@KurgerBing281 They're officially insolvent not bankrupt. There's an important difference: Declaring insolvency gives you some protection and breathing space to try to get your feet back under you - quite a lot of companies have been insolvent in the past and made a successful comeback.

There's no coming back from bankruptcy.

@nivvle
Copy link

nivvle commented Jul 18, 2023

@KurgerBing281 They're officially insolvent not bankrupt. There's an important difference: Declaring insolvency gives you some protection and breathing space to try to get your feet back under you - quite a lot of companies have been insolvent in the past and made a successful comeback.

There's no coming back from bankruptcy.

@Rhywden you might (not) want to check the latest news 🪦😑

@magnusnordlander
Copy link
Author

@KurgerBing281 I'm guessing there might be something wrong with your public key? It depends on what error you're getting.

If you're using the curl calls, just make sure to save not just the certificate, but the private key and the ECU serial as well.

@Power2All
Copy link

Power2All commented Jul 18, 2023

@nivvle @magnusnordlander
The wording "failliet" in Dutch, is the same as "bankrupt".
It means they aren't able to pay their debt, and thus are insolvent, which is the same as being bankrupt.
You can still get out a bankruptcy though, there is no difference with being insolvent.
You can find the info how it's done in Netherlands here:
https://business.gov.nl/ending-your-business/financial-problems-and-bankruptcy/bankruptcy-procedure/

Here a translation from Dutch:

The (legal) person that is declared bankrupt is called bankrupt, other terms for the legal concept of bankruptcy are bankrupt, insolvency and pleaded.

Here the original Dutch wording for it:

De (rechts-)persoon die failliet wordt verklaard heet failliet, andere termen voor het wettelijke begrip faillissement zijn bankroet, insolventie en pleite.

Ontopic:
@magnusnordlander The person I was fixing this for, noticed his bike wasn't present at the dashboard, he will let me know to try it again :)

@gregmcd
Copy link

gregmcd commented Jul 18, 2023

Thank you so much for this, super helpful!

A messy (uncleaned ChatGPT translated) version of this code in Python that worked for me is below in case you, like me, didn't have Node installed.

import base64
import requests
import nacl.signing

def getCert(email, password):
    signing_key = nacl.signing.SigningKey.generate()  # Secure random private key
    verify_key = signing_key.verify_key  # Corresponding public key

    privKey = signing_key.encode(encoder=nacl.encoding.Base64Encoder)
    pubKey = verify_key.encode(encoder=nacl.encoding.Base64Encoder)

    print("Privkey = " + privKey.decode())
    print("Pubkey = " + pubKey.decode())

    try:
        headers = {
            'Authorization': "Basic " + base64.b64encode(f"{email}:{password}".encode()).decode(),
            'Api-Key': "fcb38d47-f14b-30cf-843b-26283f6a5819",
            'User-Agent': "VanMoof/20 CFNetwork/1404.0.5 Darwin/22.3.0",
        }

        authResponse = requests.post('https://my.vanmoof.com/api/v8/authenticate', headers=headers)
        authToken = authResponse.json()['token']

        headers['Authorization'] = "Bearer "+ authToken
        appTokenResponse = requests.get('https://api.vanmoof-api.com/v8/getApplicationToken', headers=headers)
        appToken = appTokenResponse.json()['token']

        bikeDataResponse = requests.get('https://my.vanmoof.com/api/v8/getCustomerData?includeBikeDetails', headers=headers)

        for bike in bikeDataResponse.json()['data']['bikes']:
            print("Bike "+bike['name'])
            print("Bike ID: "+ str(bike['bikeId']))
            print("Frame number: "+ bike['frameNumber'])
            print("Frame serial: "+ bike['frameSerial'])

            if bike['bleProfile'] == 'ELECTRIFIED_2022':
                print("Bike is an SA5")
                print("ECU Serial: "+ bike['mainEcuSerial'])

                headers['Authorization'] = "Bearer "+appToken
                certificateResponse = requests.post(
                    f'https://bikeapi.production.vanmoof.cloud/bikes/{bike["bikeId"]}/create_certificate',
                    headers=headers, 
                    json={'public_key': pubKey.decode()})

                print("Certificate below:")
                print("-----------")
                print(certificateResponse.json())
                print("-----------")
            else:
                print("Not an SA5.")
    except Exception as error:
        print(error)

getCert("YOUR_VANMOOF_EMAIL", 'YOUR_VANMOOF_PASSWORD')

@KurgerBing281
Copy link

KurgerBing281 commented Jul 19, 2023

@KurgerBing281 I'm guessing there might be something wrong with your public key? It depends on what error you're getting.

@magnusnordlander Yeah, that's probably it. I used ssh-keygen -t ed25519. Then applied awk '{ printf $2 }' my_public_key.txt, both with and without piping to base64. It was helpful to see your script apply base64. I always mess up my encryption. That's why I appreciated your approach, for an integrated solution, verified by others than myself.
curl mainly spat out errors when not using the --data-urlencode option, complaining about some malformed URL (probably the padding = characters in base64). However, subsequent attempts using the correct syntax/options gave no result at all. Just nothing. And an exit code of 0. Figured it might also be some server ddos protection, preventing too many attempts in a short timeframe. Alternatively, replacing production with test in the URL gave the same results (the IP address was also the same for both test and production, for what it's worth).

If you're using the curl calls, just make sure to save not just the certificate, but the private key and the ECU serial as well.

Yes, I repeatedly saved the bike details and the successful private/public keys.

In my bike details JSON some other API calls were mentioned by the way. With a little adjusting I could get some other hashes, that I saved just to be sure. I think it was replacing v8 by api/v8 in the URL.


About the translation issues: I only guessed that "failliet" translated to "bankrupt", couldn't come up with a better word. But I admit to not having it checked by Google Translate 🙂 (just thrilled that I finally got it working).


@gregmcd awesome! Being so focused on getting nodejs to work, it didn't even occur to me that python could be a great alternative. For old-fashioned people like me, that's a little less "exotic" than nodejs.

Also new to the whole GitHub thing (used to GitLab), so I missed the notifications at first. Nice to see this remains an active topic.

@magnusnordlander
Copy link
Author

@KurgerBing281 I've never gotten ssh-keygen to work to generate the keys. Known-good (to me) implementations are @noble/ed25519, ed25519swift and Google Tinkey (though I never got it to work with the CLI, just with code). I'm sure other implementations work as well, but those are the one I've tested.

@KurgerBing281
Copy link

@gregmcd The python script worked! ✔️
Just had to install pynacl (which is still imported as nacl) using pip
(off-topic: so I guess npm is to nodejs, what pip is to python).
Thanks for your work.


@magnusnordlander Somewhat reassuring I'm not the only one being baffled by ssh-keygen sometimes. Thanks for the other tips.

Off-topic: I noted ed25519swift is made available by one 'pebble8888' - made me remember my old Pebble watch 💔, and the subsequent Rebble-servers that replaced the offline official Pebble servers after fitbit killed the Pebble brand. Should the same thing happen to VanMoof, I hope some community will provide a similar service.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment