Merge pull request #6755 from Yawning/feature/pem

core/encoding/pem: Initial import
This commit is contained in:
Jeroen van Rijn
2026-06-03 17:22:52 +02:00
committed by GitHub
5 changed files with 394 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/*
Encodes and decodes PEM formatted data.
See:
- [[ https://www.rfc-editor.org/rfc/rfc7468.html ]]
*/
package pem

299
core/encoding/pem/pem.odin Normal file
View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)
}