Home Page
  • October 03, 2024, 08:10:23 pm *
  • Welcome, Guest
Please login or register.

Login with username, password and session length
Advanced search  

News:

Official site launch very soon, hurrah!


Author Topic: Password transfer script for Roboform to BitWarden  (Read 2822 times)

Dakusan

  • Programmer Person
  • Administrator
  • Hero Member
  • *****
  • Posts: 543
    • View Profile
    • Dakusan's Domain
Password transfer script for Roboform to BitWarden
« on: July 19, 2024, 03:57:03 am »



I created this script because I wasn’t happy with the native Roboform importer inside Bitwarden. This fixed multiple problems including:
* Ignoring MatchUrls
* Parent folders weren’t created if they had no items in them (e.x. if I had identities in Financial/Banks, but nothing in /Financial, then it wouldn’t be created /Financial)
* Completely ignoring the extra fields (RfFieldsV2)


This fixes all those problems.


This needs to be ran via php in a command line interface, so `php convert.php`. It requires the [bitwarden cli “bw” command](https://bitwarden.com/help/cli/).
There are 2 optional arguments you can pass:
1) The name of the import file. If not given, `./RfExport.csv` will be used
2) The “bw” session token. If not given as a parameter, it can be set directly inside this file on line #4. You get this token by running `bw unlock` (after logging in with `bw login`).


This script does the following:
* Reads from a csv file exported by Roboform to import into bitwarden
* Runs `bw sync` before processing
* Imported Fields:
 * Name: Becomes the name of the item
 * Url: Becomes the URL for the item
 * MatchUrl: If this is not the same “Url” then it is added as another URL for the item
 * Login: Becomes the login (user) name
 * Pwd: Becomes the password
 * Note: Becomes the note
 * Folder: Item is put in this folder
 * RfFieldsV2 (multiple):
   * Fields that match the login or password fields are marked as “linked fields” to those. If the field names are the following, they are not added as linked fields: *user, username, login, email, userid, user id, user id$, user_email, login_email, user_login, password, passwd, password$, pass, user_password, login_password, pwd, loginpassword*
   * Fields that are different than the login or password are added as appropriate “Custom fields”. Supported types: '', rad, sel, txt, pwd, rck, chk, are
   * Each field has 5 values within it, and 3 of those seem to be different types of names. So as long as they are not duplicates of each other, each name is stored as a seperate field.
* If all fields are blank but “name” and “note” then the item is considered a “Secure Note”
* While reading the csv file for import, errors and warnings are sent to stdout
* After the csv import has complete, it will give a total number of warnings/errors and ask the user if they want to continue
* Creates missing folders (including parents that have no items in them)
* During export to bitwarden:
 * This process can be quite slow since each instance of running “bw” has to do a lot of work
 * Keeps an active count of items processed and the total number of items
 * If duplicates are found (same item name and folder) then the user is prompted for what they want to do. Which is either s=skip or o=overwrite. A capitol of those letters can be given to perform the same action on all subsequent duplicates.
 * Errors are sent to stdout


Download script here

<?php
global $BW_SESSION;
/** @noinspection SpellCheckingInspection,RedundantSuppression */
$BW_SESSION=($argv[2] ?? 'FILL_ME_IN');

print RunMe($argv)."\n";

//Make sure “bw” is installed, given session is valid, and is synced
function CheckBWStatus(): ?string
{
   if(is_string($StatusCheck=RunBW('status')))
      return $StatusCheck;
   else if(!is_object($StatusCheck) || !isset($StatusCheck->status))
      return 'bw return does not have status';
   else if($StatusCheck->status==='locked')
      return 'bw is locked';
   else if($StatusCheck->status!=='unlocked')
      return 'bw status is invalid';

   $ExpectedMessage='Syncing complete.';
   if(is_string($SyncCheck=RunBW('sync', null, false)))
      return $SyncCheck;
   else if($SyncCheck[0]!==$ExpectedMessage)
      return "Sync expected “${ExpectedMessage}” but got “${SyncCheck[0]}”";

   return null;
}

//Pull the known folders
function PullKnownFolders(): string|array
{
   $FolderIDs=[];
   if(is_string($FolderList=RunBW('list folders')))
      return $FolderList;
   foreach($FolderList as $Folder)
      $FolderIDs[$Folder->name]=$Folder->id;
   unset($FolderIDs['No Folder']);
   return $FolderIDs;
}

//Get the file handle and the column indexes
function GetFileAndColumns(): string|array
{
   //Prepare the import file for reading
   ini_set('default_charset', 'UTF-8');
   $FileName=$argv[1] ?? './RfExport.csv';
   if(!file_exists($FileName))
      return 'File not found: '.$FileName;
   else if(!($f=fopen($FileName, 'r')))
      return 'Error opening file: '.$FileName;

   //Check the header row values
   if(!($HeadRow=fgetcsv($f)))
      return 'Error opening file: '.$FileName;
   else if(!count($HeadRow) || $HeadRow[0]===NULL)
      return 'Missing head row: '.$FileName;
   if(str_starts_with($HeadRow[0], "\xEF\xBB\xBF")) //Remove UTF8 BOM
      $HeadRow[0]=substr($HeadRow[0], 3);
   unset($FileName);

   $ExpectedCols=array_flip(['Url', 'Name', 'MatchUrl', 'Login', 'Pwd', 'Note', 'Folder', 'RfFieldsV2']);
   $ColNums=[];
   foreach($HeadRow as $Index => $Name) {
      if(!isset($ExpectedCols[$Name]))
         if(isset($ColNums[$Name]))
            return 'Duplicate column title: '.$Name;
         else
            return 'Unknown column title: '.$Name;
      $ColNums[$Name]=$Index;
      unset($ExpectedCols[$Name]);
   }
   if(count($ExpectedCols))
      return 'Required columns not found: '.implode(', ', array_keys($ExpectedCols));
   else if($ColNums['RfFieldsV2']!==count($ColNums)-1)
      return 'RfFieldsV2 must be the last column';

   return [$f, $ColNums];
}

//Process the rows
function ProcessRows($f, array $ColNums, array &$FolderIDs): array
{
   $Counts=['Error'=>0, 'Warning'=>0, 'Success'=>0];
   $RowNum=0;
   $FolderNames=[];
   $Items=[];
   while($Line=fgetcsv($f, null, ",", "\"", "")) {
      //Process the row and add result type to counts
      $RowNum++;
      $Result=ProcessRow($Line, $ColNums);
      $Counts[$Result[0]]++;

      //Handle errors and warnings
      if($Result[0]!=='Success') {
            print "$Result[0]: Row #$RowNum $Result[1]\n";
            continue;
      } else if(isset($Result['Warnings']) && count($Result['Warnings'])) {
         print "Warning(s): Row #$RowNum ".implode("\n", $Result['Warnings'])."\n";
         $Counts['Warning']+=count($Result['Warnings']);
      }

      //Add the folder to the list of folders (strip leading slash)
      $FolderName=($Line[$ColNums['Folder']] ?? '');
      $FolderName=substr($FolderName, ($FolderName[0] ?? '')==='/' ? 1 : 0);
      $Result['Data']->folderId=&$FolderIDs[$FolderName];
      $FolderNames[$FolderName]=1;

      //Save the entry
      $Items[]=$Result['Data'];
   }
   fclose($f);
   return [$Counts, $FolderNames, $Items, $RowNum];
}

//Process a single row
function ProcessRow($Line, $ColNums): array
{
   //Skip blank lines
   if(!count($Line) || (count($Line)===1 && $Line[0]===null))
      return ['Warning', 'is blank'];

   //Extract the columns by name
   $C=(object)[];
   foreach($ColNums as $Name => $ColNum)
      $C->$Name=$Line[$ColNum] ?? '';

   //Check for errors and end processing early for notes
   if($C->Name==='')
      return ['Error', 'is missing a name'];
   else if($C->Url==='' && $C->MatchUrl==='' & $C->Login==='' && $C->Pwd==='') {
      return ['Success', 'Data'=>(object)['type'=>2, 'notes'=>$C->Note, 'name'=>$C->Name, 'secureNote'=>['type'=>0]]];
   }

   //Create a login card
   $Ret=(object)[
      'type'=>1,
      'name'=>$C->Name,
      'notes'=>$C->Note===''  ? null : $C->Note,
      'login'=>(object)[
         'uris'=>[(object)['uri'=>$C->Url]],
         'username'=>$C->Login,
         'password'=>$C->Pwd,
      ],
   ];
   if($C->MatchUrl!==$C->Url)
      $Ret->login->uris[]=(object)['uri'=>$C->MatchUrl];

   //Create error string for mist fields
   $i=0; //Declared up here so it can be used in $FormatError
   $FormatError=function($Format, ...$Args) use ($ColNums, &$i): string {
      return 'RfFieldsV2 #'.($i-$ColNums['RfFieldsV2']+1).' '.sprintf($Format, ...$Args);
   };

   //Handle misc/extra (RfFieldsV2) fields
   $Warnings=[];
   for($i=$ColNums['RfFieldsV2']; $i<count($Line); $i++) {
      //Pull in the parts of the misc field
      if(count($Parts=str_getcsv($Line[$i], ",", "\"", ""))!==5)
         return ['Error', $FormatError('is not in the correct format')];
      $Parts=(object)array_combine(['Name', 'Name3', 'Name2', 'Type', 'Value'], $Parts);
      if(!trim($Parts->Name))
         return ['Error', $FormatError('invalid blank name found')];

      //Figure out which “Names” to process
      $PartNames=[trim($Parts->Name)];
      foreach([$Parts->Name2, $Parts->Name3] as $NewName)
         if($NewName!=='' && !in_array($NewName, $PartNames))
            $PartNames[]=$NewName;

      //Process the different names
      foreach($PartNames as $PartName) {
         //Determined values for the item
         $LinkedID=null;
         $PartValue=$Parts->Value;
         /** @noinspection PhpUnusedLocalVariableInspection */ $Type=null; //Overwritten in all paths

         //Handle duplicate usernames and password fields
         if(($IsLogin=($PartValue===$C->Login)) || $PartValue===$C->Pwd) {
            if($Parts->Type!==($IsLogin ? 'txt' : 'pwd'))
               $Warnings[]=$FormatError('expected type “%s” but got “%s”', $IsLogin ? 'txt' : 'pwd', $Parts->Type);
            $Type=3;
            $PartValue=null;
            $LinkedID=($IsLogin ? 100 : 101);

            //If a common name then do not add the linked field
            /** @noinspection SpellCheckingInspection */
            if(in_array(
               strToLower($PartName),
               $IsLogin ?
                  ['user', 'username', 'login', 'email', 'userid', 'user id', 'user id$', 'user_email', 'login_email', 'user_login'] :
                  ['password', 'passwd', 'password$', 'pass', 'user_password', 'login_password', 'pwd', 'loginpassword']
            ))
               continue;
         } else {
            //Convert the type
            switch($Parts->Type) {
               case '': //For some reason some text fields have no type given
               case 'rad':
               case 'sel':
               case 'txt': $Type=0; break;
               case 'pwd': $Type=1; break;
               case 'rck': //Radio check?
                  $Type=0;
                  if($PartName!==$Parts->Name) //Ignore second names for radio checks
                     continue 2;
                  if(count($RadioParts=explode(':', $PartName))!==2)
                     return ['Error', $FormatError('radio name needs 2 parts separated by a colon')];
                  $PartName=$RadioParts[0];
                  $PartValue=$RadioParts[1];
                  break;
               case 'chk':
                  $Type=2;
                  if($PartValue==='*' || $PartValue==='1')
                     $PartValue='true';
                  else if($PartValue==='0')
                     $PartValue='false';
                  else
                     return ['Error', $FormatError('invalid value for chk type “%s”', $PartValue)];
                  break;
               case 'are': //This seems to be a captcha
                  continue 2;
               default:
                  return ['Error', $FormatError('invalid field type “%s”', $Parts->Type)];
            }
         }

         //Create the return object
         if(!isset($Ret->fields))
            $Ret->fields=[];
         $Ret->fields[]=(object)[
            'name'=>$PartName,
            'value'=>$PartValue,
            'type'=>$Type,
            'linkedId'=>$LinkedID,
         ];
      }
   }

   //Return finished item and warnings
   if(count($Warnings))
      $Warnings[0]="RfFieldsV2 warnings:\n".$Warnings[0];
   return ['Success', 'Data'=>$Ret, 'Warnings'=>$Warnings];
}

//Ask the user if they want to continue
function ConfirmContinue($Counts, $RowNum): ?string
{
   //Ask the user if they want to continue
   printf("Import from spreadsheet is finished. Total: %d; Successful: %d, Errors: %d, Warnings: %d\n", $RowNum, $Counts['Success'], $Counts['Error'], $Counts['Warning']);
   while(1) {
      print 'Do you wish to continue the export to bitwarden? (y/n): ';
      fflush(STDOUT);
      switch(trim(strtolower(fgets(STDIN)))) {
         case 'n':
            return 'Exiting';
         case 'y':
            break 2;
      }
   }

   return null;
}

//Get folder IDs and create parent folders for children
function GetFolders($FolderNames, &$FolderIDs): ?string
{
   foreach(array_keys($FolderNames) as $FolderName) {
      //Skip “No Folder”
      if($FolderName==='') {
         $FolderIDs[$FolderName]='';
         continue;
      }

      //Check each part of the folder tree to make sure it exists
      $FolderParts=explode('/', $FolderName);
      $CurPath='';
      foreach($FolderParts as $Index => $FolderPart) {
         $CurPath.=($Index>0 ? '/' : '').$FolderPart;

         //If folder is already cached then nothing to do
         if(isset($FolderIDs[$CurPath])) {
            continue;
         }

         //Create the folder
         print "Creating folder: $CurPath\n";
         if(is_string($FolderInfo=RunBW('create folder '.base64_encode(json_encode(['name'=>$CurPath])), 'create folder: '.$CurPath)))
            return $FolderInfo;
         else if (!isset($FolderInfo->id))
            return "bw folder create failed for “${CurPath}”: Return did not contain id";
         $FolderIDs[$CurPath]=$FolderInfo->id;
      }
   }
   return null;
}

//Pull the known items
function PullKnownItems($FoldersByID): string|array
{
   $CurItems=[];
   if(is_string($ItemList=RunBW('list items')))
      return $ItemList;
   foreach($ItemList as $Item) {
      $CurItems[($Item->folderId===null ? '' : $FoldersByID[$Item->folderId].'/').$Item->name]=$Item->id;
   }
   return $CurItems;
}

function StoreItems($Items, $CurItems, $FoldersByID): int
{
   print "\n"; //Give an extra newline before starting the processing
   $NumItems=count($Items);
   $NumErrors=0;
   $HandleDuplicatesAction=''; //The "always do this" action
   foreach($Items as $Index => $Item) {
      //Clear the current line and print the processing status
      printf("\rProcessing item #%d/%d", $Index+1, $NumItems);
      fflush(STDOUT);

      //If the item already exists then request what to do
      $FullItemName=($Item->folderId===null ? '' : $FoldersByID[$Item->folderId].'/').$Item->name;
      if(isset($CurItems[$FullItemName])) {
         //If no "always" action has been set in $HandleDuplicatesAction then ask the user what to do
         if(!($Action=$HandleDuplicatesAction)) {
            print "\n";
            while(1) {
               print "A duplicate at “${FullItemName}” was found. Choose your action (s=skip,o=overwrite,S=always skip,O=always overwrite):";
               fflush(STDOUT);
               $Val=trim(fgets(STDIN));
               if(in_array(strtolower($Val), ['s', 'o']))
                  break;
            }
            $Action=strtolower($Val);
            if($Val!==$Action) //Upper case sets the "always" state
               $HandleDuplicatesAction=$Action;
         }

         //Skip the item
         if($Action==='s')
            continue;

         //Overwrite the item
         if(is_string($Ret=RunBW('edit item '.$CurItems[$FullItemName].' '.base64_encode(json_encode($Item)), 'edit item: '.$FullItemName))) {
            $NumErrors++;
            printf("\nError on item #%d: %s\n", $Index+1, $Ret);
         }
         continue;
      }

      //Create the item
      if(is_string($Ret=RunBW('create item '.base64_encode(json_encode($Item)), 'create item: '.$FullItemName))) {
         $NumErrors++;
         printf("\nError on item #%d: %s\n", $Index+1, $Ret);
      }
   }
   return $NumErrors;
}

//Run a command through the “bw” application
function RunBW($Command, $Label=null, $DecodeJSON=true): string|object|array
{
   //$Label is set to $Command if not given
   if($Label===null)
      $Label=$Command;

   //Run the command and check the results
   global $BW_SESSION;
   exec(sprintf('BW_SESSION=%s bw %s --pretty 2>&1', $BW_SESSION, $Command), $Output, $ResultCode);
   if($ResultCode===127)
      return 'bw is not installed';
   else if($ResultCode===1)
      return "bw “${Label}” threw an error: \n".implode("\n", $Output);
   else if($ResultCode!==0)
      return "bw “${Label}” returned an invalid status code [$ResultCode]: \n".implode("\n", $Output);
   else if($Output[0]==='mac failed.')
      return 'Invalid session ID';
   else if(!$DecodeJSON)
      return [implode("\n", $Output)];
   else if(($JsonRet=json_decode(implode("\n", $Output)))===null)
      return "bw “${Label}” returned non-json result";

   //Return the json object
   return $JsonRet;
}

function RunMe($argv): string
{
   //Make sure “bw” is installed and given session is valid
   if(is_string($Ret=CheckBWStatus()))
      return $Ret;

   //Pull the known folders
   if(is_string($FolderIDs=PullKnownFolders()))
      return $FolderIDs;

   //Get the file handle and the column indexes
   if(is_string($Ret=GetFileAndColumns()))
      return $Ret;
   [$f, $ColNums]=$Ret;
   unset($argv);

   //Process the rows and ask the user if they want to continue
   [$Counts, $FolderNames, $Items, $RowNum]=ProcessRows($f, $ColNums, $FolderIDs);
   if(is_string($Ret=ConfirmContinue($Counts, $RowNum)))
      return $Ret;
   unset($Counts, $RowNum, $f, $ColNums);

   //Get folder IDs and create parent folders for children
   if(is_string($Ret=GetFolders($FolderNames, $FolderIDs)))
      return $Ret;
   unset($FolderNames);

   //Pull the known items
   $FoldersByID=array_flip($FolderIDs);
   if(is_string($CurItems=PullKnownItems($FoldersByID)))
      return $CurItems;

   //Store all items
   $FolderIDs['']=null; //Change empty folder to id=null for json insertions
   $NumErrors=StoreItems($Items, $CurItems, $FoldersByID);

   //Return completion information
   return "\nCompleted ".($NumErrors ? "with $NumErrors error(s)" : 'successfully');
}
Logged