moonseal

    File and message encryption for MoonBit, for one or several recipients.

    encryption
    age
    recipients
    stanza
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    20

    #moonseal

    Seal a file for several people at once; any one of them opens it.

    A sealed file is a header of recipient stanzas over a streaming payload. Each stanza carries the same sixteen-byte file key, wrapped so that one reader can unwrap it, and the payload is encrypted in chunks under a key derived from that file key. A file addressed to four people is one file, not four, and any one of those people opens it on their own.

    There is no key ring, no trust model and no key server. Where keys come from, and whether a public key belongs to the person it names, are the caller's business — this library has no state to keep them in, and a format that kept some would no longer be a format.

    // Alice has a key pair; the public half is what a sender needs.
    let alice = @x25519.Identity::generate(random=@system.source) catch {
    _ => abort("no randomness")
    }
    println(alice.code()) // AGE-SECRET-KEY-1…
    println(alice.public().code()) // age1…

    let recipients : Array[&@spec.Recipient] = [alice.public()]
    let file = @moonseal.seal(recipients, b"the quick brown fox", random=@system.source) catch {
    _ => abort("cannot seal")
    }

    let readers : Array[&@spec.Identity] = [alice]
    println(@moonseal.open(readers, file)) // b"the quick brown fox"

    #Packages

    PackageWhat it doesSpecification
    moonsealSealing and opening, in memory or as a stream, with or without an armorage v1
    specThe error type, the stanza, and the two traits a recipient type implements
    headerThe stanzas and the MAC that binds them to the payloadage v1
    streamThe payload: chunks of 64 KiB, each authenticated on its ownage v1
    armorThe same file as text, when a binary cannot travelRFC 7468
    x25519A recipient type for a public keyRFC 7748
    scryptA recipient type for a passphraseRFC 7914

    Import the ones you use. Nothing here is linked until something calls it.

    #Install

    moon add moonbitstack/moonseal moon add moonbitstack/moonrand

    @system above is moonbitstack/moonrand/system, the operating system's random source.

    #Use

    #A passphrase instead of a key

    A passphrase is a recipient type of its own, and it has to be alone in the header: a file under one is a file whose only reader is whoever knows it, and mixing it with a public key would let somebody be told a weaker story about who else can read the file.

    let file = @moonseal.seal(
    [@scrypt.Recipient::new("correct horse battery staple", log_n=14)],
    b"the quick brown fox",
    random=@system.source,
    ) catch {
    _ => abort("cannot seal")
    }
    let readers : Array[&@spec.Identity] = [
    @scrypt.Identity::new("correct horse battery staple"),
    ]
    println(@moonseal.open(readers, file))

    The work factor is the one knob that trades a reader's time for a guesser's. Eighteen is the default; the format stops at twenty-two, which is four gibibytes of scratch space, and a reader can set its own ceiling with max_log_n.

    #A file larger than memory

    seal and open take bytes and give bytes, which is what most callers want. When the file is too large for that, the same format is available over a source and a sink: a chunk is sealed, written and forgotten, and nothing bigger than one chunk plus a header is ever held.

    let sink = @moonseal.BytesSink::new()
    @moonseal.seal_stream(
    sink,
    [@x25519.Recipient::of("age1…") catch { _ => abort("no") }],
    @moonseal.BytesSource::new(b"the quick brown fox"),
    random=@system.source,
    ) catch {
    _ => abort("cannot seal")
    }

    BytesSource and BytesSink are the in-memory pair; anything with a read and a write can implement Source and Sink instead, which is how a file, a socket or a pipe gets attached without this library knowing about any of them.

    #Text instead of bytes

    Somewhere along the way a file has to survive an email. seal_armored writes the same file as a -----BEGIN AGE ENCRYPTED FILE----- block, and open_armored reads it back; the streaming forms do the same without holding the text either.

    let text = @moonseal.seal_armored(
    [@x25519.Recipient::of("age1…") catch { _ => abort("no") }],
    b"the quick brown fox",
    random=@system.source,
    ) catch {
    _ => abort("cannot seal")
    }
    println(text)

    Reading is stricter than writing, on purpose. Whitespace around the block and either line ending are allowed, because mail clients add one and platforms disagree about the other. Nothing inside is: a line that is not sixty-four columns, a block whose length is not a multiple of four after padding, or a second copy of the same bytes are all refusals, because a second spelling of one file is a second file to everybody downstream.

    #A recipient type of your own

    The header carries stanzas it does not understand, which is what leaves room for a type this library has never heard of. Implement two methods and a file can be addressed to it:

    struct Example {}

    impl @spec.Recipient for Example with fn wrap(self, file_key~, random~) {
    ignore(self)
    ignore(random)
    // The body is whatever the matching identity can unwrap. This one hands the
    // file key over as it is, which is not a design to use; a real type draws
    // its ephemeral material with `@spec.draw(random, len)`.
    @spec.Stanza::new(kind="example.com/plain", args=[], body=file_key.to_owned())
    }

    impl @spec.Identity for Example with fn unwrap(self, stanza~) {
    ignore(self)
    if stanza.kind == "example.com/plain" {
    return Some(stanza.body)
    }
    None
    }

    None means "not mine, try the next stanza", and it is the answer for a type this identity does not implement as well as for one that is addressed to somebody else. A stanza that claims to be this type and is malformed is a refusal instead, because at that point the file is broken rather than somebody else's.

    #The interface

    #moonseal

    seal(recipients, plain, random~) -> BytesSeal bytes for a list of recipients.
    open(identities, cipher) -> BytesOpen a file with any one of these identities.
    seal_armored(recipients, plain, random~) -> StringThe same file, as text.
    open_armored(identities, text) -> BytesOpen an armored block.
    seal_stream(sink, recipients, source, random~)Seal from a source into a sink.
    open_stream(sink, identities, source)Open from a source into a sink.
    seal_stream_armored(sink, recipients, source, random~)The same, as text.
    open_stream_armored(sink, identities, source)The same, as text.
    drain(source) -> BytesEverything a source has.
    BytesSource::new(BytesView) / BytesSink::new() / BytesSink::bytes()The in-memory pair.
    Source::read(limit~) / Sink::write(bytes~)What a caller implements to attach a file, a socket or a pipe.

    #spec

    RefusedWhy a call did not do what it was asked: a header that is not the format, a stanza of a known type that is not either, a MAC that does not verify, no identity that opens anything, a chunk that does not authenticate, armor that is not armor, a key of the wrong size, a random source that failed, an input past a limit.
    Stanza { kind, args, body }One recipient stanza, as the header carries it.
    draw(random, len) -> BytesBytes from a random source, with its failure as a Refused.
    Recipient::wrap(self, file_key~, random~) -> StanzaWhat a recipient type implements to address a file.
    Identity::unwrap(self, stanza~) -> Bytes?What it implements to open one.

    #header, stream, armor

    Header::decode(bytes) -> (Header, Int)Read a header, and say where the payload starts.
    Header::encode(file_key~) -> BytesWrite it, MAC line and all.
    Header::new(stanzas) / verify(file_key~)Build one; check the MAC of one that was read.
    Header::stanzas()The stanzas, in the order they are written.
    stream.chunk / stream.tag / stream.nonce64 KiB, 16 bytes, 16 bytes.
    stream.payload_key(file_key~, nonce~)The payload key for one file.
    Sealer::chunk(plain~, last~) / Opener::chunk(cipher~, last~)One chunk at a time, for callers building their own plumbing.
    armor.encode(bytes) / armor.decode(text)The text form.
    armor.Encoder / armor.DecoderThe same a piece at a time.

    #x25519, scrypt

    x25519.Identity::generate(random~) / Identity::new(scalar)A secret key, from randomness or from bytes.
    x25519.Identity::of(text) / code()AGE-SECRET-KEY-1… in and out.
    x25519.Identity::public() / Recipient::of(text) / Recipient::code()age1… in and out.
    scrypt.Recipient::new(passphrase, log_n?) / scrypt.Identity::new(passphrase, max_log_n?)A passphrase, with the work factor to write and the one to accept.

    #Where the randomness comes from

    From the caller. Every call that needs randomness takes random, a moonrand Source: @system.source from moonrand/system in a program, or anything else that implements the trait. A file draws in a fixed order: sixteen bytes for the file key, then what each recipient type needs for its stanza (thirty-two for X25519, sixteen for scrypt), then sixteen for the payload nonce. A source that fails is a Refused::Random, raised before anything is written.

    The fixed order makes sealing reproducible: the same plaintext, the same recipients and a Replay of the same bytes give the same file, byte for byte, which is what the test suite uses to pin the format.

    #What a file looks like

    age-encryption.org/v1 -> X25519 aQwdcB4/zsAqbGhOXDVcZMGvC5J5bTdhqqZyiK6V9l0 n3ccxIiZEos8us5SboQVs0swdWU9NV2gbGJgYKAu+Xk --- A7bGuYb5XUntlaP2+ePJxehnm4uFu1f3Loora2+eEmg <16-byte nonce><chunks of 64 KiB, each with a 16-byte tag>

    The MAC is HMAC-SHA-256 over the header up to and including its ---, keyed from the file key, which is why the header is verified before a single byte of payload is decrypted: whoever can edit the list of stanzas can edit who reads the file. The payload key is derived from the file key and the nonce, so two files sealed under the same recipients differ from their first byte.

    #What is deliberately elsewhere

    ThingWhy not here
    Key storage, trust, rotation, revocationAll four need state, and a library with state is no longer a format.
    A random-number generatormoonrand has them; the caller picks one and hands it in.
    Certificates and tokensThose assert who somebody is. This is a message, and asserts nothing.

    #Licence

    Apache-2.0. See LICENSE.

    Sink

    pub(open) trait Sink {
    fn write(Self, bytes~ : BytesView) -> Unit raise
    Refused

    }

    Where bytes go. It may be called with small pieces, and it is called in the order the file is written.

    Source

    pub(open) trait Source {
    fn read(Self, limit~ : Int) -> Bytes raise
    Refused

    }

    Where bytes come from. A file, a socket, a buffer: anything that can hand over a run of bytes and say when it has no more.

    read may return fewer bytes than it was asked for; an empty answer means the end. The reader in this library asks for a chunk at a time and keeps asking until it has what it needs or the source is spent.

    BytesSink

    pub struct BytesSink {
    // private fields
    }

    A sink over a buffer, for the same reason.
    impl Sink for BytesSink

    BytesSink::bytes

    fn BytesSink::bytes(self : BytesSink) -> Bytes

    Everything written so far, as one byte string.

    BytesSink::new

    fn BytesSink::new() -> BytesSink

    BytesSink::write

    fn BytesSink::write(self : BytesSink, bytes~ : BytesView) -> Unit raise
    Refused

    BytesSource

    pub struct BytesSource {
    // private fields
    }

    A source over bytes that are already in memory, which is what a caller with a file in a buffer wants, and what the tests use.

    BytesSource::new

    fn BytesSource::new(bytes : BytesView) -> BytesSource

    BytesSource::read

    fn BytesSource::read(self : BytesSource, limit~ : Int) -> Bytes raise
    Refused

    drain

    fn drain(source : &Source) -> Bytes raise
    Refused

    The whole of a source, which is what a caller does when the input is small enough to hold and they would rather see it than stream it.

    open

    fn open(identities : Array[&
    Identity
    ], cipher : BytesView) -> Bytes raise
    Refused

    Open a file addressed to any one of identities.

    The header is checked before the payload is touched: a stanza is opened to recover the file key, the MAC over the header is verified with it, and only then does a chunk get decrypted. A header that does not verify means somebody edited the list of who can read the file, and no part of the payload is worth looking at after that.

    open_armored

    fn open_armored(identities : Array[&
    Identity
    ], text : StringView) -> Bytes raise
    Refused

    Open an armored block.

    open_stream

    fn open_stream(sink : &Sink, identities : Array[&
    Identity
    ], source : &Source) -> Unit raise
    Refused

    Open a file from a source, writing the plaintext to a sink as it is authenticated. Nothing is written until the header has verified.

    open_stream_armored

    fn open_stream_armored(sink : &Sink, identities : Array[&
    Identity
    ], source : &Source) -> Unit raise
    Refused

    Open an armored text block from a source.

    seal

    Seal plain for recipients, and give back the file.

    The file key, every stanza's ephemeral material and the payload nonce come out of random, in that order: @system.source from moonrand/system in a program, a Replay of fixed bytes in a test that wants the same file twice. A source that fails is a refusal, and it arrives before the first byte is written.

    How much a file draws: sixteen bytes for the file key, then thirty-two for each X25519 recipient and sixteen for each scrypt recipient, then sixteen for the payload nonce.

    seal_armored

    The same as seal, written out as an armored text block.

    seal_stream

    The same file, written a piece at a time: nothing larger than one chunk is ever held, which is what makes a file bigger than memory something this library can seal.

    seal_stream_armored

    Seal to a sink, as an armored text block. The text is written in the same pieces the file is, so the whole of it never exists at once either.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io