Welcome back! This is part two of a series of blog posts involving a script to automatically change EDGAR preferences. You do not need to read the first blog post to understand most of the concepts of this post but it is recommended.
Friday, March 24. 2017
LDC #27: Automatically Changing the EDGAR Preferences Part 2
As technology advances, the need for information security is at an all time high. Users and developers often focus on speed and ease of use over protecting data. It’s much faster for a user if all his or her relevant information is saved as it reduces data entry time. It’s much easier for developers to save the user data in plain text as it reduces development time and testing. This results in a potentially dangerous situation where private information is assumed to be safe but may not be. In this blog we will discuss how to secure private information as well as talk about security considerations when scripting. Many of the topics discussed here can be applied to other languages as well.
One of the first steps to securing a Legato script is figuring out what parts if any need to be secured. This may seem a little odd but not everything needs to be secured. There are many factors in deciding what portions of a script require protection but two important questions are: “who has access to the script” and “what is the flow of private information.” Does the end user already have access to any data the script uses? If the end user already knows all data that the script accesses, that data probably doesn’t need to be protected. When thinking about access, it is important to consider those users who are not the target end user. For example, consider a script that allows for user defined find and replace actions. Are there multiple users? Should one user be able to see another’s defined actions?
The flow of private information essentially asks: “what does the script do with user information?” Is information used and then forgotten, saved until the application closes, or retained through restarting the program? Is the information written into files or other external sources? Is the data transmitted across a network or even the Internet? Private information also isn’t necessarily limited to user information. Think about a script that connects to a database. Where are the database credentials stored? Does the user enter them each time?
A common mistake in analyzing security needs occurs when you assume that the proper use case is the only use case. Even in normal situations, a user may accidentally access data that he or she shouldn’t or try something unexpected. This is a much larger topic but it is related as it may result in leaked private data. It is also important to note that since Legato is a interpreted language, users may be able to read the source code, so storing data in the source code may not be secure. Legato has provisions to secure script code that will be discussed later in this blog.
After deciding what data needs to be secured, the next step is figuring out how to secure the data. If information is transmitted over the Internet, it may be as simple as ensuring the script uses HTTPS requests instead of HTTP. If the data is instead located in an email, the contents might need to be encrypted or sent as a link to a secure website. Settings saved between sessions can also be encrypted. However, does the user need to enter a password to decrypt them or is the encryption password stored in the script source code? Again, these are the sorts of considerations to make when designing security measures for your script.
Legato offers some functions to help secure data. When you’re dealing with script settings, the easiest functions to use are the GetEncryptedSetting and PutEncryptedSetting SDK functions. These functions retrieve and store encrypted data in an Windows INI file format. These functions do not require an encryption key since the same key is used by all GoFiler applications. This means the end user does not need to enter a password or key and likewise a password or key is not stored in the Legato source code. It also means any GoFiler application can decrypt the settings. However, following from that, users with access to the application can decrypt the settings by writing their own Legato scripts.
Legato also offers two other encryption methods that can be more secure since the encryption key is set by the developer (or, by extension, the user). These functions are the EncryptAsRC4, EncryptAsAES, and DecryptAsAES functions. Both RC4 and AES are standard encryption protocols. RC4 has multiple vulnerabilities and as such is considered insecure, but it is still used by some protocols and is generally faster than AES. On the other hand, AES has no known practical attacks that work when the method is correctly implemented. In this article, we’ll examine AES.
AES is a symmetrical algorithm meaning the process of encryption and decryption are the same. Data that is encrypted twice with the same key is actually encrypted then decrypted. However, since AES internally uses a block size of 128 bits (16 bytes) the encrypted data must be padded or be a multiple of 16 bytes. This padding means the encrypted data may not be the same size as the decrypted data. Because of this, Legato uses separate encrypt and decrypt functions to automatically add or remove the padding as needed.
Since a key is required for these functions, it will either need to be entered by the user or stored in a location the user will be able to access.
Here is the complete copy of the altered script from last week. In the discussion that follows, we will only cover the changed sections.
// Predefines int setup (); int on_open_project (int f_id, string mode); string get_agent_table (); string read_cik (); string read_cik_offset (handle edit_window, string name, int offy, int offx); string read_cik_formd (handle edit_window); string read_cik_mfp (handle edit_window); // Globals for Encryption byte iv[16]; #define MYKEY "My 'Strong' Password ^_^" // Set up int setup() { string fnScript; // Get Script fnScript = GetScriptFilename(); // Hook Project Open MenuSetHook(MenuFindFunctionID("PSEUDO_OPENED_PROJECT"), fnScript, "on_open_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_PROJECT"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_SUBMISSION"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_13F"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_13H"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_17A"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_17H"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_C"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_D"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_MA"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_N-MFP"), fnScript, "on_new_project"); MenuSetHook(MenuFindFunctionID("FILE_NEW_XML_FORM_SDR"), fnScript, "on_new_project"); return ERROR_NONE; } // Get Agent Table string get_agent_table() { handle hSRC; char data[]; char result[]; string lines[]; int size, ix, rc; hSRC = OpenFile(AddPaths(GetScriptFolder(), "agents.enc")); if (IsError(hSRC)) { // Fall back to unencrypted if available return FileToString(AddPaths(GetScriptFolder(), "agents.csv")); } size = GetFileSize(AddPaths(GetScriptFolder(), "agents.enc")); data[size + 128] = 0; result[size + 128] = 0; size = ReadBlock(hSRC, data, size); if (IsError(size)) { return ""; } // Decrypt it rc = DecryptAsAES(MYKEY, data, result, AES_MODE_CBC, iv, size); if (IsError(rc)) { SetLastError(rc); return ""; } result[rc] = 0; // Check Signature lines = ExplodeString(result); if (lines[0] != "Legato Encrypted CSV Table") { SetLastError(ERROR_SYNTAX, "The specified file is not the correct format."); return ""; } // Get File Minus first line data = ""; size = ArrayGetAxisDepth(lines); for (ix = 1; ix < size; ix++) { data += lines[ix] + "\r\n"; } return data; } // Read CIK from Open Project string read_cik() { dword window_type; handle edit_window; edit_window = GetEditWindowHandle(); if (IsError(edit_window)) { return ""; } window_type = GetEditWindowType(edit_window); window_type &= EDX_TYPE_ID_MASK; if (window_type == EDX_TYPE_EDGAR_VIEW) { return read_cik_offset(edit_window, "section_filer", 3, 1); } if (window_type == EDX_TYPE_XML_13F_VIEW) { return read_cik_offset(edit_window, "cik", 0, 0); } if (window_type == EDX_TYPE_XML_13H_VIEW) { return read_cik_offset(edit_window, "filerId", 0, 0); } // EDX_TYPE_XML_17_VIEW - Not defined in current SDK version if (window_type == 0x000001E0) { return read_cik_offset(edit_window, "cik", 0, 0); } // EDX_TYPE_XML_17H_VIEW - Not defined in current SDK version if (window_type == 0x00000210) { return read_cik_offset(edit_window, "cik", 0, 0); } // EDX_TYPE_XML_C_VIEW - Not defined in current SDK version if (window_type == 0x000001D0) { return read_cik_offset(edit_window, "cik", 0, 0); } if (window_type == EDX_TYPE_XML_D_VIEW) { return read_cik_formd(edit_window); } if (window_type == EDX_TYPE_XML_MA_VIEW) { return read_cik_offset(edit_window, "filerId", 0, 0); } if (window_type == EDX_TYPE_XML_MFP_VIEW) { return read_cik_mfp(edit_window); } // EDX_TYPE_XML_SDR_VIEW - Not defined in current SDK version if (window_type == 0x000001A8) { return read_cik_offset(edit_window, "cik", 0, 0); } // EDX_TYPE_XML_RGA_VIEW - Not defined in current SDK version if (window_type == 0x000001C0) { return read_cik_offset(edit_window, "cik", 0, 0); } return ""; } // Read CIK using Offset string read_cik_offset(handle edit_window, string name, int offy, int offx) { string field; handle dataview; int location[2]; dataview = DataViewGetObject(edit_window, 0); field = DataViewFindCellByName(dataview, name); if (field != "") { location = CellAddressToIndex(field); return DataViewCellGetText(dataview, location[0] + offy, location[1] + offx); } return ""; } // Special read CIK from Form D string read_cik_formd(handle edit_window) { string field; handle dataview; int location[2]; dataview = DataViewGetObject(edit_window, 1); field = DataViewFindCellByName(dataview, "primaryIssuer"); if (field != "") { location = CellAddressToIndex(field); return DataViewCellGetText(dataview, location[0] + 2, location[1]); } return ""; } // Special read CIK from Form N-MFP string read_cik_mfp(handle edit_window) { string field; handle dataview; int location[2]; dataview = DataViewGetObject(edit_window, 0); field = DataViewFindCellByName(dataview, "filer:cik"); if (field != "") { location = CellAddressToIndex(field); field = DataViewCellGetText(dataview, location[0], location[1]); if (field != "") { return field; } } dataview = DataViewGetObject(edit_window, 4); field = DataViewFindCellByName(dataview, "filer:cik"); if (field != "") { location = CellAddressToIndex(field); field = DataViewCellGetText(dataview, location[0], location[1]); if (field != "") { return field; } } return ""; } // Globals for Table string ciks[]; string cccs[]; string passwords[]; int default_cik; int load_agent_table() { string datafile; string lines[]; string values[]; string s1; int size, ix; // Load Agent Preferences List datafile = get_agent_table(); if (datafile == "") { MessageBox('x', "Could not read data file 0x%08X", GetLastError()); return ERROR_EOD; } default_cik = 0; lines = ExplodeString(datafile); size = ArrayGetAxisDepth(lines); for (ix = 0; ix < size; ix++) { values = ExplodeString(lines[ix], ","); if (values[0] == "") { break; } s1 = ReplaceInStringRegex(values[0], "(\\d+)$", "0000000000$1"); ciks[ix] = ReplaceInStringRegex(s1, "0*(\\d{10})$", "$1"); cccs[ix] = values[1]; passwords[ix] = values[2]; if (values[3] != "") { default_cik = ix; } } return ERROR_NONE; } // Hook for Project Open int on_open_project(int f_id, string mode) { string s1; int size, rc, ix; if (mode != "postprocess") { return ERROR_NONE; } rc = load_agent_table(); if (IsError(rc)) { return rc; } // Get Filer CIK s1 = read_cik(); size = ArrayGetAxisDepth(ciks); ix = 0; while (ix < size) { if (s1 == ciks[ix]) { break; } ix++; } if (ix >= size) { ix = default_cik; } if (ix != default_cik) { rc = YesNoBox("Change Agent CIK to match Filer CIK?"); if (rc != IDYES) { ix = default_cik; } } // Set them rc = EDGARSetCredentials(ciks[ix], passwords[ix], cccs[ix]); if (IsError(rc)) { MessageBox('x', GetLastErrorMessage()); } return ERROR_NONE; } // Hook for New Project int on_new_project(int f_id, string mode) { dword window_type; handle edit_window; string s1; int size, rc, ix; if (mode != "postprocess") { return ERROR_NONE; } // Check Type edit_window = GetEditWindowHandle(); if (IsError(edit_window)) { return ERROR_NONE; } window_type = GetEditWindowType(edit_window); window_type &= EDX_TYPE_ID_MASK; if ((window_type != EDX_TYPE_EDGAR_VIEW) && (window_type != EDX_TYPE_XML_13F_VIEW) && (window_type != EDX_TYPE_XML_13H_VIEW) && // EDX_TYPE_XML_17_VIEW - Not defined in current SDK version (window_type != 0x000001E0) && // EDX_TYPE_XML_17H_VIEW - Not defined in current SDK version (window_type != 0x00000210) && // EDX_TYPE_XML_C_VIEW - Not defined in current SDK version (window_type != 0x000001D0) && (window_type != EDX_TYPE_XML_D_VIEW) && (window_type != EDX_TYPE_XML_MA_VIEW) && (window_type != EDX_TYPE_XML_MFP_VIEW) && // EDX_TYPE_XML_SDR_VIEW - Not defined in current SDK version (window_type != 0x000001A8) && // EDX_TYPE_XML_RGA_VIEW - Not defined in current SDK version (window_type != 0x000001C0)) { return ERROR_NONE; } rc = load_agent_table(); if (IsError(rc)) { return rc; } // Set to Default rc = EDGARSetCredentials(ciks[default_cik], passwords[default_cik], cccs[default_cik]); if (IsError(rc)) { MessageBox('x', GetLastErrorMessage()); } return ERROR_NONE; } int main() { setup(); return ERROR_NONE; }
The first change you’ll notice is the addition of new global variable and a new string literal definition. As stated above storing passwords in the source code isn’t always a good choice but later on in this blog we will cover how to secure hard coded passwords.
// Globals for Decryption byte iv[16]; #define MYKEY "My 'Strong' Password ^_^"
The key is the password used by the DecryptAsAES function; we can make this a string literal as it won’t change throughout the script. AES also requires an initialization vector (or IV) depending on the block chaining technique used. In this script, we will use iv for that vector. The IV adds randomness to the start of the encrypted file. If two files begin with the same text and are encrypted with the same key, the beginning of the encrypted files will match. With different IVs, they will not match and it becomes harder for a third party to reverse the key. In the “real world”, the IV should be different for every file that is encrypted. We could save the IV as the first 16 bytes of the encrypted file and read the IV before decrypting but for this example we will just hard code it. We also employ the most basic vector (all 0s), but we could set iv to something else as long as the same value is used to encrypt the data.
Most of the script remains as we described last week. However, there was a point where we were describing the programmer-defined get_agent_table function where we mentioned that the function could be expanded on at a later date. This is where that expansion comes into play. Previously it was just one line of code, which returned a hard-coded location of our CSV file that contains the agent table. Now it’s going to deal with the decryption of that table.
string get_agent_table() { handle hSRC; char data[]; char result[]; string lines[]; int size, ix, rc; hSRC = OpenFile(AddPaths(GetScriptFolder(), "agents.enc")); if (IsError(hSRC)) { // Fall back to unencrypted if available return FileToString(AddPaths(GetScriptFolder(), "agents.csv")); } size = GetFileSize(AddPaths(GetScriptFolder(), "agents.enc")); data[size] = 0; result[size + 128] = 0; size = ReadBlock(hSRC, data, size); if (IsError(size)) { return ""; } // Decrypt it rc = DecryptAsAES(MYKEY, data, result, AES_MODE_CBC, iv, size); if (IsError(rc)) { SetLastError(rc); return ""; } result[rc] = 0; // Check Signature lines = ExplodeString(result); if (lines[0] != "Legato Encrypted CSV Table") { SetLastError(ERROR_SYNTAX, "The specified file is not the correct format."); return ""; } // Get File Minus first line data = ""; size = ArrayGetAxisDepth(lines); for (ix = 1; ix < size; ix++) { data += lines[ix] + "\r\n"; } return data; }
First, you’ll notice that we’ve defined a couple of character arrays where we’re going to store our results. Note that because the length of the file is unknown at this point, we don’t specify exactly how big we want them. We’ll handle that in a bit. Secondly, we’re not attempting to open the CSV file anymore. Instead, this becomes a fallback if the encrypted version of the file is not available. Using the OpenFile SDK function, we retrieve a handle to our encrypted agents file (again, the location of which is hard-coded into the script). We then get the size of the file with the GetFileSize function.
hSRC = OpenFile(AddPaths(GetScriptFolder(), "agents.enc")); if (IsError(hSRC)) { // Fall back to unencrypted if available return FileToString(AddPaths(GetScriptFolder(), "agents.csv")); } size = GetFileSize(AddPaths(GetScriptFolder(), "agents.enc")); data[size] = 0; result[size + 128] = 0;
With the size of the file, we can now define the size of our character arrays. Because the data and result arrays were not defined with a fixed size, these lines now force Legato to size the arrays. The extra 128 bytes added to the size of the result array is a buffer on the end in case the decrypted table is larger than the encrypted table. As stated above AES can increase the size of the encrypted file so this shouldn’t happen, but the it’s a few extra bytes for safety.
Once we have our arrays ready and our file handle, we can read the contents with the ReadBlock function.
size = ReadBlock(hSRC, data, size); if (IsError(size)) { return ""; }
And we can decrypt what we just read with the DecryptAsAES SDK function. We hand the function our predefined key, the array containing our data, and the result array. We also specifying the block-chaining mode with the value AES_MODE_CBC and our initialization vector iv. The CBC block-chaining mode removes patterns in the encrypted data by using the last block’s value (or the IV for the first block) in the encryption.
Finally we supply the size of our data buffer.
// Decrypt it rc = DecryptAsAES(MYKEY, data, result, AES_MODE_CBC, iv, size); if (IsError(rc)) { SetLastError(rc); return ""; } result[rc] = 0;
If the function fails, we return an empty string. Otherwise we terminate our character array at the last position that was decrypted as returned by the DecryptAsAES function. Because of the symmetrical nature of AES the DecryptAsAES function does not indicate whether the file was actually decrypted. If the wrong key of IV is used the function actually ends up encrypting the data again. So even though the function did not return an error it does not mean the file was decrypted.
Before returning the resulting table, we should check to make sure the file was successfully decrypted. There are a few ways to do this but in our example we put a simple header at the top of the CSV. If the decryption failed that header will not be there.
We can explode our result array by line endings with the ExplodeString function, and if the first line is not what we expect, we can set an error and return an empty string. Otherwise, the result array is rebuilt without that first line and the contents of our file, now decrypted and ready to go, is passed back to the calling function as a string.
// Check Signature lines = ExplodeString(result); if (lines[0] != "Legato Encrypted CSV Table") { SetLastError(ERROR_SYNTAX, "The specified file is not the correct format."); return ""; } // Get File Minus first line data = ""; size = ArrayGetAxisDepth(lines); for (ix = 1; ix < size; ix++) { data += lines[ix] + "\r\n"; }
Again, note that this is just as it was in last week’s script when all the get_agent_table did was open the file and return the contents, so the rest of our script from last week can proceed in the exact same manner and adjust the EDGAR preferences as necessary.
Now that we have a script that reads an encrypted table, we need a way to actually encrypt that table. We could make a function hook in this script that allows the user to encrypt a table, but that means any user can replace our table. So in this case we opted for a separate script, which appears as follows:
// Globals for Encryption - Must match Above File byte iv[16]; #define MYKEY "My 'Strong' Password ^_^" // Encrypt Table int create_encrypted_table() { string data; char result[]; int size, ix, rc; data = BrowseOpenFile("Select CSV file...", "CSV Files (*.csv)|*.csv"); if (data == "") { return ERROR_CANCEL; } data = FileToString(data); if (data == "") { MessageBox('x', "Could not read data file 0x%08X", GetLastError()); return ERROR_NONE; } data = "Legato Encrypted CSV Table\r\n" + data; // Force Result to size properly result[GetStringLength(data) + 128] = 0; // Encrypt it rc = EncryptAsAES(MYKEY, data, result, AES_MODE_CBC, iv); if (IsError(rc)) { MessageBox('x', "Could not encrypt file 0x%08X", rc); return ERROR_NONE; } size = rc; VariableToFile(result, AddPaths(GetScriptFolder(), "agents.enc"), size); MessageBox('i', "Encrypted file created. (%d bytes)", size); return ERROR_NONE; } int main() { int rc; handle hLog; create_encrypted_table(); rc = ScriptCrunch(AddPaths(GetScriptFolder(), "AutoAgent.ms"), AddPaths(GetScriptFolder(), "AutoAgentCrunched.ms")); if (IsError(rc)) { hLog = GetLastErrorLog(); LogDisplay(hLog, "Crunch Log"); } else { MessageBox('i', "Script file successfully encrypted and crunched"); } return ERROR_NONE; }
Our second script has a standard main function and a programmer-defined function called create_encrypted_table. Let’s take a closer look at the latter first. After defining some variables we need, we use the BrowseOpenFile SDK function to open a browse dialog to the user. If no file is selected, the function returns an error. Otherwise, the FileToString SDK function is called on the specified file. We again do some error checking and alert the user if the file could not be read.
data = BrowseOpenFile("Select CSV file...", "CSV Files (*.csv)|*.csv"); if (data == "") { return ERROR_CANCEL; } data = FileToString(data); if (data == "") { MessageBox('x', "Could not read data file 0x%08X", GetLastError()); return ERROR_NONE; }
With our file contents that we need to encrypt now stored in a string, let’s work on getting it into the right format. First, the function adds the top line of what will be our encrypted file. Note that this is the line we checked as a signature in the get_agent_table function in our other script. Once we have the line in place, we size our result character array by getting the length of our string (in other words, the size of the file) and adding 128 bytes of padding.
The EncryptAsAES function is then called, and it uses the key, the mode, and the initialization vector to encrypt the data array and place it into the result array. It’s extremely important to use the same key and initialization vector here as is used to decrypt the file. We check for an error, and if there was none, we store the size of the encrypted data as supplied in the return value of the EncryptAsAES function.
data = "Legato Encrypted CSV Table\r\n" + data; // Force Result to size properly result[GetStringLength(data) + 128] = 0; // Encrypt it rc = EncryptAsAES(MYKEY, data, result, AES_MODE_CBC, iv); if (IsError(rc)) { MessageBox('x', "Could not encrypt file 0x%08X", rc); return ERROR_NONE; } size = rc;
After encrypting our file, it’s a simple matter of writing out our data to the encrypted CSV file using the VariableToFile function. We alert the user the encryption was successful with a message box.
VariableToFile(result, AddPaths(GetScriptFolder(), "agents.enc"), size); MessageBox('i', "Encrypted file created. (%d bytes)", size);
The create_encrypted_table function is called by the script’s main function. After that, we take an additional step to protect our private information.
rc = ScriptCrunch(AddPaths(GetScriptFolder(), "AutoAgent.ms"), AddPaths(GetScriptFolder(), "AutoAgentCrunched.ms")); if (IsError(rc)) { hLog = GetLastErrorLog(); LogDisplay(hLog, "Crunch Log"); } else { MessageBox('i', "Script file successfully encrypted and crunched"); }
This is an optional step, but considering we store vital information in the script itself (such as our key and initialization vector), it seems prudent to protect it. The ScriptCrunch SDK function conceals the content of the code. There are three main processes that the ScriptCrunch function performs: (i) it strips all comments and compacts the code into a computer readable version of the script; (ii) it optionally compresses and encrypts the code; and finally, (iii) it optionally applies a digital signature using a code signing certificate managed by Windows.
Crunching with encryption, as we have done here, is an effective way to hide any internal passwords, URLs or other information. It also means that script can be documented without risking a nefarious user easily taking the script apart. As part of the compact process, all referenced files including header (.h) and resources (.rc) are included such that the resulting file becomes a standalone single-file script. Note that a script can also be crunched in the IDE by clicking on the Crunch/Integrate ribbon button under Script | Prepare.
The ScriptCrunch file can also sign the Legato script using a code signing certificate. The crunch function uses any valid code signing certificate in the current user’s certificate store. If more than one certificate is available it uses the certificate with the most time remaining. Signing scripts is a good way to detect if a third party has altered the script in anyway. For now, Legato can sign and verify script signatures, in the long term users will have the option of only allowing signed scripts to be executed for added security.
So that concludes our discussion of encryption. It’s an important consideration when private information is used, passed through, and included in scripting. With the tools we’ve covered here, Legato can help you protect private data, access and use it for a variety of purposes, and conceal your code itself when necessary.
David Theis has been developing software for Windows operating systems for over fifteen years. He has a Bachelor of Sciences in Computer Science from the Rochester Institute of Technology and co-founded Novaworks in 2006. He is the Vice President of Development and is one of the primary developers of GoFiler, a financial reporting software package designed to create and file EDGAR XML, HTML, and XBRL documents to the U.S. Securities and Exchange Commission. |
Additional Resources
Legato Script Developers LinkedIn Group
Primer: An Introduction to Legato
Quicksearch
Categories
Calendar
November '24 | ||||||
---|---|---|---|---|---|---|
Mo | Tu | We | Th | Fr | Sa | Su |
Thursday, November 21. 2024 | ||||||
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 |