OpenSSL Library - BIO 概論
BIO 是 OpenSSL 庫為了處理資料輸出入所設計的輸出入抽象層,參考《bio(3)》的說明。 OpenSSL 的程式碼經常利用 BIO 的多形性,故在使用 OpenSSL 開發應用程式時,必須先熟悉 BIO。
BIO 的設計模式是 C 語言 (不是C++) 實作個體導向程式設計多形性(polymorphism of OOP)時常見的設計方式。 在早期,程序員學了 OOP 的觀念可是還是要寫 C 程式的時代,我們需自己用 C 語言實作類別繼承、動態連結等內容。但我們用的是 C compiler 而非 C++ compiler ,所以很多事我們必須自己處理。 因此它們的程式碼與近代 C++ 式的表達方式有所差異。 例如我在《程式語言中的介面,在個體之間協議互動行為的多種形式》說的作法就是一例;GNOME Library 也是這種用 C 語言寫出來的「類別庫」。 所以 BIO 實際上是一種類別庫。
BIO 類別輪廓
既然 BIO 是一種類別庫,那麼我們最好還是用看待類別的方式去看 BIO ,才容易看清它的輪廓。 本節將使用 C++ 的語法表達 BIO 的類別內容,以便理解其繼承與多形關係。 為了方便各位參考 OpenSSL 說明文件草稿,我在類別名稱第一次出現的地方,都會在其後用角括號寫出其 C 語言的原名與文件連結。原名名稱括弧內的數字,是 Unix man page 的表達習慣,表示那屬於 man page 的第幾號分類。
因為 BIO 類別庫的內容非常多,本節只是選了常用的幾個來表達。完整的內容請查看 OpenSSL 的 bio 標頭文件 (/usr/include/openssl/bio*.h)。
BIO 基礎類別
BIO [BIO_new(3)] 類別是所有 BIO 類別庫的基礎類別。它宣告了 BIO_METHOD 介面。所有 BIO 子類別都必須實作此介面。
在《程式語言中的介面,在個體之間協議互動行為的多種形式》說到介面宣告在 C 語言眼中其實就是一個以結構型態定義多個函數指標成員的「函數表」。BIO_METHOD 正是這種作為「介面」的函數表。程序員想配置新的 BIO 個體時,可以調用 BIO 子類別的建構子得之,亦可將子類別實作的介面傳遞給 BIO 類別的建構子得之。「介面」可以當成參數傳遞,這一點是初學者比較難以理解的特性。
class BIO_METHOD {
int type;
virtual int read(void *data, int len);
virtual int gets(char *buf, int size);
virtual int write(const void *data, int len);
virtual int puts(const char *buf);
};
class BIO : BIO_METHOD {
public:
BIO(BIO_METHOD *type); // BIO_new()
/*
BIO_METHOD 是一個函數指標表,其用途即 OOPL 的 interface,
每一個函數指標即 interface 中的抽象函數,
當建構實體時,才會將指標指向實際的函數。
最初的 C 語言便是透過這種模式實作繼承機制。
type:
BIO_s_file()
BIO_s_mem()
and more...
*/
~BIO(); // BIO_free()
int read(void *data, int len); // BIO_read()
int gets(char *buf, int size); // BIO_gets()
int write(const void *data, int len); // BIO_write()
int puts(const char *buf); // BIO_puts()
// manpage fread(),fgets(),fwrite(),fputs()
int tell(); // BIO_tell()
int seek(long offset); // BIO_seek()
// manpage fseek(3), ftell(3)
int printf(...); // BIO_printf()
int snprintf(...); // BIO_snprintf()
// manpage printf(3)
int ctrl(...); // BIO_ctrl()
};
BIO_file 與 BIO_fd 類別
BIO_file [BIO_s_file(3)] 是BIO 的子類別之一,它對應 ANSI C 標準庫的 FILE 處理函數。其主要對象是檔案系統中的文件與標準輸出入設備(stdin, stdout, stderr)。
BIO_file 定義了兩個建構旗標: BIO_CLOSE 與 BIO_NOCLOSE,用以表明解構個體時,其 FILE 對象是否需要關閉。通常 FILE 對象為標準輸出入設備時,因為它們是由系統開啟,所以都要使用 BIO_NOCLOSE 選項表示不要關閉。
BIO_fd [BIO_s_fd(3)] 對應 POSIX 庫的檔案描述子(file descriptor)處理函數。其對象是所有可用檔案描述子開啟的設備。 它也同樣使用 BIO_CLOSE 與 BIO_NOCLOSE 表明解構時是否需要關閉設備。
// close_flag
#define BIO_NOCLOSE 0
#define BIO_CLOSE 1
class BIO_file: public BIO {
public:
BIO_file(FILE *stream, int close_flag); // BIO_new_fp()
BIO_file(const char *filename, const char *mode); // BIO_new_file()
// manpage fopen(3)
};
class BIO_fd: public BIO {
public:
BIO_fd(int fd, int close_flag); // BIO_new_fd()
// manpage open(2)
};
BIO_mem 類別
BIO_mem [BIO_s_mem(3)] 的對象是記憶體區塊。它把記憶體區塊當成一個設備,對它進行的讀取與寫入動作實際上是記憶體的資料複製行為。 配合大部份的 C 語言函數需要直接傳遞記憶體區塊的指標,故它也定義了一個 get_mem_ptr() 方法。
class BIO_mem: public BIO {
public:
BIO_mem(); // BIO_new(BIO_s_mem())
BIO_mem(void *buf, int len); // BIO_new_mem_buf()
struct BUF_MEM {
void *data;
int length;
int max;
};
int get_mem_ptr(BUF_MEM **ptr);
};
BIO_mem 的好處在於會自已配置並維護它持有的記憶體區塊,並隨寫入的資料量主動調整區塊大小。
除此之外,它還有一個比較特殊的預設行為,當你指定一個已配置的記憶體區塊給它時,它會是一個唯讀設備。此時你只能透過它從該記憶體區塊中讀取資料,但不能透過它寫入(改變)該區塊的內容。例如:
char buf[4096]; // fixed-size memory block
BIO *bio1 = new BIO_mem(buf, sizeof(buf)); // read-only
bio1->puts("hello"); // nothing changed.
BIO *bio2 = new BIO(BIO_s_mem()); // dynamic-allocate memory block
bio2->puts("hello"); // store "hello" into the memory block.
BIO_socket 等類別
BIO_socket [BIO_s_socket(3)] 對應了 socket 設備處理函數,其對象是 socket 型態為 SOCK_STREAM 的設備。 另外還有 BIO_dgram 處理 socket 型態為 SOCK_DGRAM 的設備; BIO_accept [BIO_s_accept(3)] 的對象是 socket 函數 accept(2) 開啟的設備;BIO_connect [BIO_s_connect(3)] 的對象是 socket 函數 connect(2) 開啟的設備。
class BIO_socket: public BIO {
public:
BIO_socket(int sockfd, int close_flag); // BIO_new_socket()
// manpage socket(2), type SOCK_STREAM
};
class BIO_dgram: public BIO {
public:
BIO_dgram(int fd, int close_flag);
// manpage socket(2), type SOCK_DGRAM
};
class BIO_connect: public BIO {
public:
BIO_connect(char *host_port);
// manpage connect(2)
};
class BIO_accept: public BIO {
public:
BIO_accept(char *host_port);
// manpage accept(2)
};
使用 BIO 類別
基本操作
我將寫一個基本的範例程式,分別用 BIO_file, BIO_mem 與 BIO_fd 類別開啟4個設備,並寫入一行文字。
我首先用 C++ 語法寫出範例程式的內容。接著再改寫為 C 語法。
C++ 偽碼
bio_pseudo.cpp 是用 C++ 語法表達 OpenSSL BIO 類別內容的偽碼,故雖可編譯但不能產生執行檔。
// gcc -c bio_pseudo.cpp
#include <cstdio>
#include "bio.hpp"
#include "bio_file.hpp"
#include "bio_mem.hpp"
int foo(BIO *bio, const char *msg) {
return bio->puts(msg);
}
int main() {
BIO *bio1 = new BIO_file(stdin, BIO_NOCLOSE);
BIO *bio2 = new BIO_file("/tmp/abc", "w");
// dynamic allocate memory block.
BIO_mem *bio3 = new BIO_mem();
BIO *bio4 = new BIO_fd(1, BIO_NOCLOSE); // 1 is stdout
foo(bio1, "bio1 say\n"); // put to screen
foo(bio2, "bio2 say\n"); // put to file '/tmp/abc'
foo(bio3, "bio3 say\n"); // put to memory
foo(bio4, "bio4 say\n"); // put to screen
BIO_mem::BUF_MEM *mem_ptr = NULL;
bio3->get_mem_ptr(&mem_ptr);
printf("size of mem ptr: %d; max: %d; data: %s",
mem_ptr->length, mem_ptr->max, (char*)mem_ptr->data);
return 0;
}
C 程式碼
bio_example.c 的 C 語言程式碼才是真正調用 OpenSSL BIO 類別庫的範例程式。
// gcc -lssl -o bio_example bio_example.c
#include <stdio.h>
#include <openssl/bio.h>
#include <openssl/buffer.h>
int foo(BIO *bio, const char *msg) {
return BIO_puts(bio, msg);
}
int main() {
BIO *bio1 = BIO_new_fp(stdout, BIO_NOCLOSE);
BIO *bio2 = BIO_new_file("/tmp/abc", "w");
// dynamic allocate memory block.
BIO *bio3 = BIO_new(BIO_s_mem());
BIO *bio4 = BIO_new_fd(1, BIO_NOCLOSE);
foo(bio1, "bio1 say\n"); // put to screen
foo(bio2, "bio2 say\n"); // put to file '/tmp/abc'
foo(bio3, "bio3 say\n"); // put to memory
foo(bio4, "bio4 say\n"); // put to screen
BUF_MEM *mem_ptr = NULL;
BIO_get_mem_ptr(bio3, &mem_ptr);
printf("size of mem ptr: %d; max: %d; data: %s",
mem_ptr->length, mem_ptr->max, mem_ptr->data);
return 0;
}
編譯與執行結果如下所示。因為開啟的四個設備中,bio1, bio4 是標準輸出設備,故寫入的文字會直接出現在螢幕上。 bio2 則是檔案系統的檔案 /tmp/abc,文字被存入其中。bio3 是記憶體,所以文字被存入記憶體。
$ gcc -lssl -o bio_example bio_example.c
$ ./bio_example
bio1 say
bio4 say
size of mem ptr: 9; max: 16; data: bio3 say
$ cat /tmp/abc
bio2 say
我先用 C++ 寫出偽碼,再改寫成 C 語言碼。這是要讓大家了解操作 BIO 類別庫時,就應該要用 OOPL 的方式思考。撰寫實際的 C 語言程式碼時,更易清晰地掌握程式流程。
加入濾器
BIO 還提供一種濾器,可讓我們插入資料流中,幫我們在讀寫資料的過程中過濾資料內容。 最常見的就是資料編碼與解碼濾器,例如 BIO::Base64 濾器 – BIO_f_base64(3)。
範例程式碼 bio_base64.c 是我將 BIO_f_base64(3) 文件所附的範例程式加以擴充所得。 範例程式在資料流中加入了 BIO::Base64 濾器,因此寫入資料流的內容都將透過此濾器被編碼為 Base64 格式後才輸出。 修改後的範例程式,利用 BIO 的多形性,使其資料流兩端可以為標準輸出入設備亦或一般檔案。
// gcc -lssl -o bio_base64 bio_base64.c
#include <openssl/bio.h>
#include <openssl/evp.h>
// program [input_filepath] [output_filepath]
// default behaviour is to read from stdin then write to stdout.
int main(int argc, char *argv[]) {
BIO *bin, *bout, *b64filter;
char buff[1024];
int rc = 0;
if (argc < 2)
bin = BIO_new_fp(stdin, BIO_NOCLOSE);
else
bin = BIO_new_file(argv[1], "r");
if (bin == NULL) {
printf("Failed to open input file.\n");
return 1;
}
if (argc > 2)
bout = BIO_new_file(argv[2], "w");
else
bout = BIO_new_fp(stdout, BIO_NOCLOSE);
if (bin == NULL) {
printf("Failed to open output file.\n");
return 1;
}
b64filter = BIO_new(BIO_f_base64());
bout = BIO_push(b64filter, bout); // insert the filter.
while ((rc = BIO_read(bin, buff, sizeof(buff))) > 0) {
BIO_write(bout, buff, rc);
}
BIO_flush(bout);
BIO_free_all(bout);
BIO_free_all(bin);
return 0;
}
此範例程式可接收兩個參數,第一個參數表示輸入的檔案名稱,第二個參數表示輸出的檔案名稱。 如果不指定第二個參數,則資料將寫入標準輸出設備。若連第一個參數也省略,則將自標準輸入設備讀取資料。
$ gcc -lssl -o bio_base64 bio_base64.c
$ cat bio_base64.c | ./bio_base64
# read from stdin, write to stdout
$ ./bio_base64 bio_base64.c
# read from bio_base64.c, write to stdout
$ ./bio_base64 bio_base64.c /tmp/b64.txt
# read from bio_base64.c, write to /tmp/b64.txt
若將 bio_base64.c 的輸出入來源改成 BIO::Socket 類別,就會支援從網路連線中讀寫資料。 當你開發支援 SSL 保密線路的網路應用程式時,資料傳輸的基本步驟也就是用 BIO::Socket 建立資料流,再插入 BIO 加密濾器,例如 BIO::Cipher – BIO_f_cipher(3)。
有興趣了解更多的人,可以參考以下三篇由 Kenneth Ballard 發表於 developerWorks 的《Secure programming with the OpenSSL API》系列文章是利用 OpenSSL 設計具有保密線路的網路程式。第一篇也是在教 BIO 的用法。
OpenSSL Library 的系列文章: