Skip to content

Instantly share code, notes, and snippets.

@advename
Created October 14, 2022 11:34
Show Gist options
  • Save advename/1b0fd1e8192c00581a9619bd5ad3ae9c to your computer and use it in GitHub Desktop.
Save advename/1b0fd1e8192c00581a9619bd5ad3ae9c to your computer and use it in GitHub Desktop.
import tls from "tls";
import crypto from "crypto";
/**
* CPR Direkte Interface (NodeJS)
*
* CPR Direkte allows for personal data retrieval (Address, Maritial Status,...) from the Danish CPR register.
*
* Unlike other service interfaces, CPR Direkte has no REST API endpoints.
* The connection is made using TCP/IP protocol.
* To quickly understand what this means, a connection to a CPR Direkte
* endpoint is made and data inbetween is send in data segments (streams).
* (It's kind of writing to a remote file, and reading responses from a remote file - where this
* interface writes to it and CPR Direkte reads our lines, and they respond with writing to the same file
* which we read from. Line by line.)
* Using predefined sequences (custom commands), one can query/manage responses.
*
* Before making CPR lookup requests, we need to make an authentication request to receive
* a token (30min lifetime) which then is attached to lookup requests.
*
* Lookup responses are one single long string, with data values always available at exact position in the string.
* For documentation regarding data contained in records, refer to "Teknisk dokumentation"
* for "CPR Direkte - PNR" on
* @see https://cprservicedesk.atlassian.net/
*
* @example
* Set the following env variables:
* CPR_DIREKTE_URL=direkte.cpr.dk
* CPR_DIREKTE_TRANSACTION_CODE=PRIV
* CPR_DIREKTE_IS_TEST=N
* CPR_DIREKTE_PORT=5000
* CPR_DIREKTE_CUSTOMER_NUMBER=
* CPR_DIREKTE_USERNAME=
* CPR_DIREKTE_PASSWORD=
*
* Then you're good to go using the interface:
* 1. Get a token:
*
* cprDirekteToken = await cprDirekte.getToken();
*
* 2. Use said token to lookup a CPR:
*
* const cprLookup = await cprDirekte.lookupCPR("1234567890", cprDirekteToken);
*
* 3. You should receive an object with following values:
* {
* cpr: '1234567890',
* birthdate: '19920510',
* sex: 'M',
* statusCode: '01',
* statusDate: '000000000000',
* protectionStartDate: '000000000000',
* formattedName: 'Johnny Johnson',
* coName: '',
* locality: '',
* streetName: 'Købmagergade',
* city: '22',
* postalCode: '1200',
* houseNumber: '012',
* floor: '4',
* buildingNumber: '',
* sideNumber: '0001',
* firstName: 'Johnny',
* lastName: 'Johnson',
* statusCodeMessage: 'bopael_i_danmark'
* }
*
* CPR Direkete test CPR's:
* - 0709614029 - Status 80
* - 3101530069 - Under guardianship (værgemål)
* - 0701614054 - Secret Adress
*/
const dataSectionStartLength = 28; // start of DATA section of response
/**
* Make the actual TCP/IP request to CPRDirekte
* The stream closes immediately after having received the first response.
* Throw's an error
* @param {string} context
* @returns {string}
* @throws {Error} TCP/IP Socket connection error. Unauthenticated requests do not trigger an error.
*/
async function makeRequest(context) {
const host = process.env.CPR_DIREKTE_URL;
const port = process.env.CPR_DIREKTE_PORT;
let socket = tls.connect(port, host);
socket.setEncoding("latin1"); // ISO-8859-1 encoding
socket.write(context);
let promise = await new Promise(function (resolve, reject) {
socket.on("data", function (response) {
socket.destroy();
resolve(response);
});
socket.on("error", function (err) {
displayConsoleError(err);
reject(err);
});
});
return promise;
}
/**
* Login to CPR Direkte to obtain authentication token for subsequent requests.
* @returns {string|null} Token or null for failed request
*/
async function getToken() {
const loginContext =
`${process.env.CPR_DIREKTE_TRANSACTION_CODE},${process.env.CPR_DIREKTE_CUSTOMER_NUMBER}90${process.env.CPR_DIREKTE_USERNAME}${process.env.CPR_DIREKTE_PASSWORD}`.padEnd(
35
);
const response = await makeRequest(loginContext);
if (
captureRequestErrorCode(response, "Unable to login. Check credentials.")
) {
return null;
}
const token = mockSubstr(response, 6, 8);
return token;
}
/**
* Lookup a CPR Number
* Only returns data if CPR status is 01, 03, 05, 07
*
* @param {string} cprNumber
* @param {string} token
* @returns {object|null} Data or null for failed request.
*/
async function lookupCPR(cprNumber, token) {
const lookupContext =
`${process.env.CPR_DIREKTE_TRANSACTION_CODE},${process.env.CPR_DIREKTE_CUSTOMER_NUMBER}06${token}${process.env.CPR_DIREKTE_USERNAME}00${cprNumber}`.padEnd(
39
);
const response = await makeRequest(lookupContext);
if (!token || cprNumber.length != 10) {
displayConsoleError(
"Invalid parameters. Check the token or CPR Number."
);
return null;
}
if (captureRequestErrorCode(response, "Failed looking up CPR Number.")) {
return null;
}
const records = getAvailableRecords(response);
// Current Data (Personal Data of the CPR Number holder)
const recordDataStart = records["001"];
const lookupData = getCurrentData(recordDataStart, response);
lookupData.statusCodeMessage = getStatusCodeMessage(lookupData.statusCode);
// Contact Address
if (records.hasOwnProperty("003")) {
// check for KONTAKT_ADDRESS in list of found records
const recordContactStart = records["003"]; // get start position in the response of contact address records
const contactAddress = mockSubstr(response, recordContactStart, 195);
lookupData.contactAddress = contactAddress; // returns a long string of data - in our use case we're only interested in the existence
}
// Under guardianship (værgemål)
if (records.hasOwnProperty("005")) {
// check for KONTAKT_ADDRESS in list of found records
const recordGuardianStart = records["005"]; // get start position in the response of contact address records
const guardian = mockSubstr(response, recordGuardianStart, 243);
lookupData.guardian = guardian; // returns a long string of data - in our use case we're only interested in the existence
}
return lookupData;
}
/**
* Login to CPR Direkte to obtain authentication token for subsequent requests.
*
* CPR Direkte Criterias:
* - Password must be 8 characters long.
* - Minimum 1 lower-case alphabetical (a-z)
* - Minimum 1 upper-case alphabetical (A-Z)
* - Minimum 1 numerical (0-9)
* - Minimum 1 special character ~ ` ! @ # $ % ^ * ( ) _ - + = , . / \ { } [ ] ; :
* - The characters < > & ? ’ æ ø å cannot be used
* - Password can only be changed once every 24 hours
* - Reuse of paswords are not allowed.
*
* @returns {string|null} Token or null for failed request
*/
async function updatePassword(newPassword) {
if (newPassword.length != 8) {
// False, password does not meet CPRDirekte criteria
displayConsoleError(
"New CPR Direkte password is not exactly 8 characters long"
);
return null;
}
const updatePasswordContext =
`${process.env.CPR_DIREKTE_TRANSACTION_CODE},${process.env.CPR_DIREKTE_CUSTOMER_NUMBER}90${process.env.CPR_DIREKTE_USERNAME}${process.env.CPR_DIREKTE_PASSWORD}${newPassword}`.padEnd(
35
);
const response = await makeRequest(updatePasswordContext);
// If errorCode = 3, then the newPassword is invalid format
if (
captureRequestErrorCode(response, "Failed password change attempt. Check new password format or credentials.")
) {
return null;
}
return true;
}
export const cprDirekte = {
getToken,
lookupCPR,
updatePassword,
generateCprDirektePassword
};
/**
* ========================
* HELPER METHODS
* ========================
*/
/**
* Generates a cryptographical password following CPR Direktes password criterias
* CPR Direkte Criterias:
* - Password must be 8 characters long.
* - Minimum 1 lower-case alphabetical (a-z)
* - Minimum 1 upper-case alphabetical (A-Z)
* - Minimum 1 numerical (0-9)
* - Minimum 1 special character ~ ` ! @ # $ % ^ * ( ) _ - + = , . / \ { } [ ] ; :
* - The characters < > & ? ’ æ ø å cannot be used
* - Password can only be changed once every 24 hours
* - Reuse of paswords are not allowed.
*
* @returns {string} 8 characters sring
*/
function generateCprDirektePassword() {
// 1. Generate an array of 8 different unique numbers between 0 - 7 (array starts at 0)
// Source: https://stackoverflow.com/a/50652249
const randomNumbersSet = new Set();
while (randomNumbersSet.size !== 8) {
randomNumbersSet.add(Math.floor(Math.random() * 8) + 0);
}
const randomNumbersArray = [...randomNumbersSet]; // convert Set to Array - Result e.g.: [3,4,1,2,5,7,6]
// 2. Generate a cryptographical random token with 8 characters (4 bytes will be 8 hex characters which are 0-9, a-z)
const randomToken = crypto.randomBytes(4).toString("hex"); // 0-9, a-z
// 3. Specify CPR Direkte password required characters
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const lower = "abcdefghijklmnopqrstuvwxyz";
const digit = "0123456789";
const special = "~`!@#$%^*()_-+=,./{}[];:"; // removed the "\" as it would escape the following character
const requiredCharactersArray = [upper, lower, digit, special];
// 4. Replace randomToken with at least one occurence of each required character set (from Step 3.)
let newPassword = randomToken;
requiredCharactersArray.forEach((requiredCharacters, index) => {
// Get random character from the requiredCharacters string set
const randomRequiredCharacter = requiredCharacters.charAt(
Math.random() * (requiredCharacters.length - 1 - 0) + 0
); // E.g. returns "K"
// Get one of the unique random numbers
const replaceAtIndex = randomNumbersArray[index]; // E.g returns 3
// Update the password to contain one of the randomly selected and randomly placed characters.
// E.g. Before "6djs8al2", with "K" at position 3 yields -> "6dKs8al2"
newPassword =
newPassword.substring(0, replaceAtIndex) +
randomRequiredCharacter +
newPassword.substring(replaceAtIndex + 1);
});
return newPassword;
}
/**
* The record structures are specified below. For each data field, usage and format is explained in concise
* form.
* Method for getting the records sent from CPR system. Note that the records available
* to you are determined when you are setup as a customer with CPR.
*
* @param $response string The response to parse records from.
* @return array Returns array of records found, as well as the index in the response where the
* record begins.
*/
function getAvailableRecords(response) {
let records = {};
let start = dataSectionStartLength;
/* if we find the start of a record, save it's starting position in the response string
as a key in associative array so we can parse it later */
while (start < response.length) {
let recordType = mockSubstr(response, start, 3);
if (recordType === "000") {
// START record
records["000"] = start; // mandatory in response
start += 35; // end of record
} else if (recordType === "001") {
// CURRENT_DATA record
records["001"] = start; // mandatory in response
start += 469; // end of record
} else if (recordType === "002") {
// FOREIGN_ADDRESS record
records["002"] = start; // NOTE: length is either 195/199 depending on record type 'A' or 'B'
start += 195; // end of record
} else if (recordType === "003") {
// KONTAKT_ADDRESS record
records["003"] = start;
start += 195; // end of record
} else if (recordType === "004") {
// MARRITAL_STATUS record
records["004"] = start;
start += 26; // end of record
} else if (recordType === "005") {
// GUARDIAN record
records["005"] = start;
start += 217; // end of record
} else if (recordType === "011") {
// CUSTOMERNUM_REF record
records["011"] = start;
start += 88; // end of record
} else if (recordType === "050") {
// CREDIT_WARNING record
// NB: Credit warning data (050 record) is first available in production from 1/1/2017
records["050"] = start;
start += 29; // end of record
} else if (recordType === "999") {
// END record
records["999"] = start; // mandatory in response
start += 21; // end of record
} else {
// console.info("Unkown record code:", recordType);
start += ~-encodeURI(recordType).split(/%..|./).length; // so we don't loop infinitely
}
}
return records;
}
/**
* Get "current data" record type out of a CPR Direkte response
* @param {number} start
* @param {string} response
* @returns {object}
*/
function getCurrentData(start, response) {
/**
* CPR Direkte Response Data Structure using positions from the interface Documentation.
* The following structure covers all CPR's having status:
* - 01: Aktiv, bopæl i dansk folkeregister
* - 03: Aktiv, speciel vejkode (9900-9999) i dansk folkeregister
* - 05: Aktiv, bopæl i grønlandsk folkeregister
* - 07: Aktiv, speciel vejkode (9900-9999) i grønlandsk folkeregister
*
* If you have to cover other status, examine the interface documentation as data positions may change.
*
* The "pos" value is one lower than in the documentation since our "mockSubstr" start position is not inclusive.
* @see https://cprservicedesk.atlassian.net/wiki/spaces/CPR/pages/11436180
*/
const dataStructure = {
cpr: {
pos: 3,
length: 10,
},
birthdate: {
// YYYYMMDD
pos: 13,
length: 8,
},
sex: {
pos: 21,
length: 1,
},
statusCode: {
// active/inactive
pos: 22,
length: 2,
},
statusDate: {
// YYYYMMDDTTMM
pos: 24,
length: 12,
},
protectionStartDate: {
// Name/Address protection start date YYYYMMDDhhmmm (0 if no protection)
pos: 70,
length: 12,
},
formattedName: {
// First/Lars or Last/First
pos: 116,
length: 34,
},
coName: {
// C/O Name
pos: 150,
length: 34,
},
locality: {
pos: 184,
length: 34,
},
streetName: {
pos: 422,
length: 20,
},
city: {
pos: 290,
length: 20,
},
postalCode: {
pos: 286,
length: 4,
},
houseNumber: {
pos: 318,
length: 4,
},
floor: {
pos: 322,
length: 2,
},
buildingNumber: {
pos: 328,
length: 4,
},
sideNumber: {
// Side, or apartment Number
pos: 324,
length: 4,
},
firstName: {
// first and middle name
pos: 332,
length: 50,
},
lastName: {
pos: 382,
length: 40,
},
};
const currentData = {};
for (const key in dataStructure) {
const keyValue = dataStructure[key];
currentData[key] = mockSubstr(
response,
start + keyValue.pos,
keyValue.length
).trim();
}
return currentData;
}
/**
* Return the meaning of a specific statuscode
* @param {string} statusCode
* @returns {string}
*/
function getStatusCodeMessage(statusCode) {
const meanings = {
// Activ
"01": "bopael_i_danmark",
"03": "bopael_i_danmark_hoej_vejkode",
"05": "bopael_i_groenland",
"07": "bopael_i_groenland_hoej_vejkode",
// Inactive
20: "ej_bopael",
30: "annulleret",
50: "nedlagt_person",
60: "Ændret personnummer",
70: "forsvundet",
80: "udrejst",
90: "doed",
};
return meanings[statusCode] || null;
}
/**
* Strip out an error code from a CPRDirekte request
*
* Error Codes:
* 00 = No errors
* 01 = USERID/PWD incorrect
* 02 = PWD expired, NEWPWD required
* 03 = NEWPWD format error
* 04 = No access to CPR
* 05 = PNR not found in CPR
* 06 = Unknown KUNDENR
* 07 = Timeout – new LOGON required
* 08 = 'DEAD-LOCK' while retrieving data from the CPR system
* 09 = Serious problem (e.g. failure to connect to the CPR system) please contact the CSC Service Center, tel (+45) 3614 6192
* 10 = ’ABON_TYPE unknown
* 11 = ’ DATA_TYPE unknown’
* 12 - 15 = (reserved error numbers)
* 16 = ’No access for your IP address’
* 17 = ‘PNR’ not entered
* 99 = USERID has no access to the transaction
*
* @see https://cprservicedesk.atlassian.net/wiki/spaces/CPR/pages/11436180/Gr+nsefladebeskrivelse+-+CPR+Direkte+Match
*
* @param {string} request
* @param {string} message
* @returns {string|null}
*/
function captureRequestErrorCode(request, message = null) {
const requestErrorCode = mockSubstr(request, 22, 2);
if (requestErrorCode != "00") {
if (message) {
// Display a console error
displayConsoleError(
`Returned with code: ${requestErrorCode}${
message != null ? " - " + message : null
}`
);
}
return requestErrorCode;
}
return null;
}
function displayConsoleError(message) {
console.error("\n[ERROR CPRDirekte] ", message, "\n");
}
/**
* Mock PHP's "substr" method using substring.
*
* All CPRDirekte examples use "substr" to retrieve the exact data positions.
*
* JavaScript's "substr" method is deprecated
*
* @param {string} string The string to apply "substr" to
* @param {number} start Start position - NOT inclusive
* @param {number} length Length - is inclusive
* @returns {string} The stripped string without touching the initial string
*/
const mockSubstr = (string, start, length) =>
string.substring(start, start + length);
@advename
Copy link
Author

  • Regarding the password renewal - I've stored the password in a Database and renewed it every 20 days (password is valid 90 days until expiration), which was triggered by a scheduled job.
  • Cpr Direkte has a test url direkte-demo.cpr.dk where you can use one of the many test CPRs that can be found on their documentation page: https://cprservicedesk.atlassian.net/
  • This code snippet is provided as it is and may be incomplete - use at your own risk!

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