diff --git a/core/encoding/pem/doc.odin b/core/encoding/pem/doc.odin new file mode 100644 index 000000000..cf5fb20a5 --- /dev/null +++ b/core/encoding/pem/doc.odin @@ -0,0 +1,7 @@ +/* +Encodes and decodes PEM formatted data. + +See: +- [[ https://www.rfc-editor.org/rfc/rfc7468.html ]] +*/ +package pem diff --git a/core/encoding/pem/pem.odin b/core/encoding/pem/pem.odin new file mode 100644 index 000000000..88ba6e2db --- /dev/null +++ b/core/encoding/pem/pem.odin @@ -0,0 +1,299 @@ +package pem + +import "base:runtime" +import "core:bufio" +import "core:bytes" +import "core:crypto" +import "core:encoding/base64" +import "core:strings" + +@(private) +BASE64_FULL_LINE_LENGTH :: 64 +@(private) +BASE64_FULL_LINE_BYTES :: (BASE64_FULL_LINE_LENGTH / 4) * 3 +@(private) +PREFIX_BEGIN : string : "-----BEGIN " +@(private) +PREFIX_END : string : "-----END " +@(private) +SUFFIX : string : "-----" +@(private) +LF :: "\n" +@(private) +PREEB_OVERHEAD :: len(PREFIX_BEGIN) + len(SUFFIX) + len(LF) +@(private) +POSTEB_OVERHEAD :: len(PREFIX_END) + len(SUFFIX) + +// Block is a block of PEM encoded data. +Block :: struct { + label: string, + data: [dynamic]byte, +} + +LABEL_CERTIFICATE :: "CERTIFICATE" // RFC 5280 +LABEL_X509_CRL :: "X509_CRL" // RFC 5280 +LABEL_CERTIFICATE_REQUEST :: "CERTIFICATE REQUEST" // RFC 2986 +LABEL_PKCS7 :: "PKCS7" // RFC 2315 +LABEL_CMS :: "CMS" // RFC 5652 +LABEL_PRIVATE_KEY :: "PRIVATE KEY" // RFC 5208/ RFC 5958 +LABEL_ENCRYPTED_PRIVATE_KEY :: "ENCRYPTED PRIVATE KEY" // RFC 5958 +LABEL_ATTRIBUTE_CERTIFICATE :: "ATTRIBUTE CERTIFICATE" // RFC 5755 +LABEL_PUBLIC_KEY :: "PUBLIC KEY" // RFC 5280 + +Decode_Error :: enum { + None, + Bad_Boundary, // Invalid boundary line. + Bad_Label, // Invalid label in BEGIN/END boundary line. + Bad_Data, // Invalid base64 data. + Label_Mismatch, // Label in END boundary line does not match. + Missing_End_Boundary, // End of data without END boundary. +} + +Error :: union #shared_nil { + runtime.Allocator_Error, + Decode_Error, +} + +// decode decodes the first encountered PEM block, returning the resulting +// block, remaining data, and nil if and only if (⟺) the process was +// successful. +// +// Note: No PEM blocks will result in this procedure returning all nils, +// and is not considered an error. +@(require_results) +decode :: proc(data: []byte, allocator := context.allocator) -> (blk: ^Block, remaining: []byte, err: Error) { + line: []byte + remaining = data + + // Search for the first `preeb`. + label: string + found := false // Label is allowed to be empty. + for len(remaining) > 0 { + line, remaining = get_line(remaining) + + label, found, err = parse_eb(line, true) + if err != nil { + return nil, nil, err + } + if found { + break + } + } + if !found { + return nil, nil, nil + } + + // RFC 1421: Parse header block. + // RFC 7468 (lax): Skip whitespace. + + // Initialize the block. + blk = new(Block, allocator) or_return + if blk.data, err = make([dynamic]byte, 0, 32, allocator); err != nil { + free(blk, allocator) + return nil, nil, err + } + if blk.label, err = strings.clone(label, allocator); err != nil { + block_delete(blk) + return nil, nil, err + } + + // Parse the `strictbase64text`. + l_buf: [BASE64_FULL_LINE_BYTES]byte + defer crypto.zero_explicit(&l_buf, size_of(l_buf)) + base64text_loop: for len(remaining) > 0 { + line, remaining = get_line(remaining) + l := len(line) + switch { + case l == 0: + block_delete(blk) + return nil, nil, .Bad_Data + case line[0] == '-': + // Looks like we hit the `posteb`, break. + break base64text_loop + case l > BASE64_FULL_LINE_LENGTH || l & 3 != 0: + // Padding is mandatory, so the line length will always + // be a multiple of 4. + block_delete(blk) + return nil, nil, .Bad_Data + } + + decoded, dec_err := base64.decode_into_buf(l_buf[:], transmute(string)(line)) + if dec_err != nil { + block_delete(blk) + return nil, nil, .Bad_Data + } + + if _, err = append(&blk.data, ..decoded); err != nil { + block_delete(blk) + return nil, nil, err + } + + // As `strictbase64text = *base64fullline strictbase64finl`, + // if we did not have a full line, we must have reached + // `strictbase64finl`. Grab what should be the `posteb` + // and break. + if l < BASE64_FULL_LINE_LENGTH { + line, remaining = get_line(remaining) + break + } + } + + // Validate the `posteb`. + post_label: string + post_label, found, err = parse_eb(line, false) + if err == nil { + switch { + case !found: + err = .Missing_End_Boundary + case label != post_label: + err = .Label_Mismatch + } + } + if err != nil { + block_delete(blk) + blk, remaining = nil, nil + } + + return +} + +// encode encodes the specified label and data into PEM format. +@(require_results) +encode :: proc(label: string, data: []byte, newline := false, allocator := context.allocator) -> (res: []byte, err: runtime.Allocator_Error) #optional_allocator_error { + sanitize_sb := proc(sb: ^strings.Builder) { + buf := sb.buf[:] + b, l := raw_data(buf), len(buf) + crypto.zero_explicit(b, l) + strings.builder_destroy(sb) + } + + sb := strings.builder_make_none(allocator) or_return + defer sanitize_sb(&sb) + + label_len := len(label) + + // Write `preeb`. + n := strings.write_string(&sb, PREFIX_BEGIN) + n += strings.write_string(&sb, label) + n += strings.write_string(&sb, SUFFIX) + n += strings.write_string(&sb, LF) + if n != PREEB_OVERHEAD + label_len { + return nil, .Out_Of_Memory + } + + // RFC 1421: Write header block. + + // Write `base64text`. + l: [BASE64_FULL_LINE_LENGTH]byte + defer crypto.zero_explicit(&l, size_of(l)) + + d := data + for len(d) > 0 { + n = min(len(d), BASE64_FULL_LINE_BYTES) + encoded, _ := base64.encode_into_buf(l[:], d[:n]) + d = d[n:] + + expected_len := len(encoded) + len(LF) + n = strings.write_bytes(&sb, encoded) + n += strings.write_string(&sb, LF) + if n != expected_len { + return nil, .Out_Of_Memory + } + } + + // Write `posteb`. + expected_len := POSTEB_OVERHEAD + label_len + (len(LF) if newline else 0) + n = strings.write_string(&sb, PREFIX_END) + n += strings.write_string(&sb, label) + n += strings.write_string(&sb, SUFFIX) + if newline { + n += strings.write_string(&sb, LF) + } + if n != expected_len { + return nil, .Out_Of_Memory + } + + res = transmute([]byte)(strings.clone(strings.to_string(sb), allocator) or_return) + + return +} + +// block_bytes returns a slice to the Block's data. +block_bytes :: proc(blk: ^Block) -> []byte { + return blk.data[:] +} + +// block_delete frees a Block returned from decode. +// +// Note: No allocator is specified as decode uses the same allocator +// for everything. +block_delete :: proc(blk: ^Block) { + allocator := ((^runtime.Raw_Dynamic_Array)(&blk.data)).allocator + + delete(blk.label, allocator) + sanitize_and_delete(blk.data) + free(blk, allocator) +} + +@(private) +get_line :: proc(data: []byte) -> (line, rest: []byte) { + adv: int + adv, line, _, _ = bufio.scan_lines(data, true) + rest = data[adv:] + + return +} + +@(private) +parse_eb :: proc(line: []byte, is_pre: bool) -> (label: string, found: bool, err: Error) { + line := line + + prefix: string + switch is_pre { + case true: + prefix = PREFIX_BEGIN + case false: + prefix = PREFIX_END + } + + l := len(line) + line = bytes.trim_prefix(line, transmute([]byte)(prefix)) + if len(line) == l { + return "", false, nil + } + + l = len(line) + line = bytes.trim_suffix(line, transmute([]byte)(SUFFIX)) + if len(line) == l { + return "", false, .Bad_Boundary + } + + // labelchar = %x21-2C / %x2E-7E ; any printable character, + // ; except hyphen-minus + // label = [ labelchar *( ["-" / SP] labelchar ) ] ; empty ok + l = len(line) + line = bytes.trim(line, []byte{'-', ' '}) + if len(line) != l { + return "", false, .Bad_Label + } + for b in line { + // We already ruled out non-labelchar start/end, so this + // allows ' '/'-'. + if b < 0x20 || b > 0x7e { + return "", false, .Bad_Label + } + } + + found = true + label = transmute(string)(line) + + return +} + +@(private) +sanitize_and_delete :: proc(data: [dynamic]byte) { + b, l := raw_data(data), len(data) + crypto.zero_explicit(b, l) + + delete(data) +} diff --git a/examples/all/all_js.odin b/examples/all/all_js.odin index becc8f522..8dbc320d0 100644 --- a/examples/all/all_js.odin +++ b/examples/all/all_js.odin @@ -71,6 +71,7 @@ package all @(require) import "core:encoding/hxa" @(require) import "core:encoding/ini" @(require) import "core:encoding/json" +@(require) import "core:encoding/pem" @(require) import "core:encoding/varint" @(require) import "core:encoding/xml" @(require) import "core:encoding/uuid" diff --git a/examples/all/all_main.odin b/examples/all/all_main.odin index a35781338..b8655a89e 100644 --- a/examples/all/all_main.odin +++ b/examples/all/all_main.odin @@ -76,6 +76,7 @@ package all @(require) import "core:encoding/hxa" @(require) import "core:encoding/ini" @(require) import "core:encoding/json" +@(require) import "core:encoding/pem" @(require) import "core:encoding/varint" @(require) import "core:encoding/xml" @(require) import "core:encoding/uuid" diff --git a/tests/core/encoding/pem/pem.odin b/tests/core/encoding/pem/pem.odin new file mode 100644 index 000000000..6da8ecd1a --- /dev/null +++ b/tests/core/encoding/pem/pem.odin @@ -0,0 +1,86 @@ +package test_core_pem + +import "core:bytes" +import "core:encoding/hex" +import "core:encoding/pem" +import "core:testing" + +// RFC 7468 Section 9. +@(private) +CMS_PEM_TEXT : string : \ +`-----BEGIN CMS----- +MIGDBgsqhkiG9w0BCRABCaB0MHICAQAwDQYLKoZIhvcNAQkQAwgwXgYJKoZIhvcN +AQcBoFEET3icc87PK0nNK9ENqSxItVIoSa0o0S/ISczMs1ZIzkgsKk4tsQ0N1nUM +dvb05OXi5XLPLEtViMwvLVLwSE0sKlFIVHAqSk3MBkkBAJv0Fx0= +-----END CMS-----` +@(private) +CMS_PEM_PAYLOAD : string : "308183060b2a864886f70d0109100109a0743072020100300d060b2a864886f70d0109100308305e06092a864886f70d010701a051044f789c73cecf2b49cd2bd10da92c48b5522849ad28d12fc849ccccb35648ce482c2a4e2db10d0dd6750c76f6f4e4e5e2e572cf2c4b5588cc2f2d52f0484d2c2a514854702a4a4dcc064901009bf4171d" +@(private) +NOT_PEM_TEXT : string : \ +` +Socialism is not in the least what it pretends to be. +It is not the pioneer of a better and finer world, but the spoiler of what thousands of years of civilization have created. +It does not build, it destroys. +For destruction is the essence of it. +It produces nothing, it only consumes what the social order based on private ownership in the means of production has created. ` +@(private) +COMMENT_TEXT : string : "# 9. Textual Encoding of Cryptographic Message Syntax" + +@(test) +test_pem_roundtrip :: proc(t: ^testing.T) { + // Decode. + blk, remaining, err := pem.decode(transmute([]byte)(CMS_PEM_TEXT)) + if !testing.expectf(t, err == nil, "PEM decode failed: %v", err) { + return + } + defer pem.block_delete(blk) + + if !testing.expectf(t, len(remaining) == 0, "PEM decode left trailing garbage: '%s'", remaining) { + return + } + + // Ensure contents match. + if !testing.expectf(t, blk.label == pem.LABEL_CMS, "PEM unexpected label: '%s'", blk.label) { + return + } + expected_payload, _ := hex.decode(transmute([]byte)(CMS_PEM_PAYLOAD)) + defer delete(expected_payload) + + if !testing.expectf(t, bytes.equal(pem.block_bytes(blk), expected_payload), "PEM unexpected data: '%x'", blk.data) { + return + } + + // Encode and compare. + encoded := pem.encode(blk.label, pem.block_bytes(blk)) + defer delete(encoded) + testing.expectf(t, CMS_PEM_TEXT == transmute(string)(encoded), "PEM encode mismatch: '%s'", encoded) +} + +@(test) +test_pem_no_blocks :: proc(t: ^testing.T) { + blk, remaining, err := pem.decode(transmute([]byte)(NOT_PEM_TEXT)) + testing.expect(t, blk == nil) + testing.expect(t, len(remaining) == 0) + testing.expect(t, err == nil) +} + +@(test) +test_pem_surrounded :: proc(t: ^testing.T) { + blob := COMMENT_TEXT + "\n" + CMS_PEM_TEXT + "\n" + NOT_PEM_TEXT + + // Should skip `COMMENT_TEXT` + blk, remaining, err := pem.decode(transmute([]byte)(blob)) + if !testing.expectf(t, err == nil, "PEM decode failed: %v", err) { + return + } + defer pem.block_delete(blk) + + // Check if the decode is correct by ensuring it round-trips. + encoded := pem.encode(blk.label, pem.block_bytes(blk)) + defer delete(encoded) + if !testing.expectf(t, CMS_PEM_TEXT == transmute(string)(encoded), "PEM encode mismatch: '%s'", encoded) { + return + } + + testing.expectf(t, NOT_PEM_TEXT == transmute(string)(remaining), "PEM remaining not preserved: '%s'", remaining) +}