A sealed file is a header of recipient stanzas over a streaming payload. Every stanza wraps the same sixteen-byte file key, so a file addressed to four people is one file and any one of them opens it. This page walks through every recipient type moonseal supports, the streaming path, the armored text form, and how to add your own recipient type. Each snippet compiles, and each printed block is the output of that snippet run as-written.
Add the library and the randomness source, then declare the packages you use in moon.pkg.
# moon.mod
name = "your-app"
source = "."
import {
"moonbitstack/moonseal",
"moonbitstack/moonseed",
}# moon.pkg (the package that uses moonseal)
import {
"moonbitstack/moonseal",
"moonbitstack/moonseal/spec",
"moonbitstack/moonseal/x25519",
"moonbitstack/moonseal/scrypt",
"moonbitstack/moonseal/ssh",
"moonbitstack/moonseal/mlkem768x25519",
"moonbitstack/moonseal/p256tag",
"moonbitstack/moonseal/mlkem768p256tag",
"moonbitstack/moonseed",
}random~ source. In a real program pass @system.source from moonbitstack/moonseed/system. The snippets below instead pin a deterministic source with @moonseed.Replay::new(Bytes::make(n, 0xNN)) so the printed output is reproducible and matches what you see here. Swap in @system.source and the bytes change but the shape does not. Core packages such as @utf8 and @bytes need no import.
The simplest recipient is a passphrase. @scrypt.Recipient::new derives a key from the phrase with scrypt; log_n sets the work factor (10 is fine for a demo, 14–18 for real use). No keys to manage — the reader supplies the same phrase to open.
fn seal_with_passphrase() -> Unit raise @spec.Refused {
let random = @moonseed.Replay::new(Bytes::make(128, 0x21))
let file = @moonseal.seal(
[@scrypt.Recipient::new("correct horse battery staple", log_n=10)],
b"the quick brown fox\n",
random=random,
)
let opened = @moonseal.open(
[@scrypt.Identity::new("correct horse battery staple")],
file,
)
println("sealed: " + file.length().to_string() + " bytes")
println("opened: " + @utf8.decode_lossy(opened))
}--- passphrase --- sealed: 202 bytes opened: the quick brown fox
For public-key use, generate an X25519 identity with @x25519.Identity::generate. Hand out identity.public() (an @spec.Recipient) to seal; keep identity (an @spec.Identity) to open. code() renders the bech32 age string; Identity::of / Recipient::of parse one back from text.
fn key_pair() -> Unit raise @spec.Refused {
let random = @moonseed.Replay::new(Bytes::make(64, 0x42))
let alice = @x25519.Identity::generate(random=random) catch {
_ => abort("no randomness")
}
println(alice.code()) // AGE-SECRET-KEY-…
println(alice.public().code()) // age1…
}
fn seal_to_recipient() -> Unit raise @spec.Refused {
let kp = @moonseed.Replay::new(Bytes::make(64, 0x42))
let alice = @x25519.Identity::generate(random=kp) catch { _ => abort("no") }
let seal_rand = @moonseed.Replay::new(Bytes::make(64, 0x21))
let file = @moonseal.seal([alice.public()], b"the quick brown fox\n", random=seal_rand) catch {
_ => abort("cannot seal")
}
println("sealed: " + file.length().to_string() + " bytes")
println("opened: " + @utf8.decode_lossy(@moonseal.open([alice], file)))
}--- keypair --- AGE-SECRET-KEY-1GFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPQ4EGAEX age1zvkyg2lqzraa2lnjvqej32nkuu0ues2s82hzrye869xeexvn73equnujwj --- recipient --- sealed: 220 bytes opened: the quick brown fox
If the reader already holds an SSH key, seal to its public half with @ssh.Recipient::of and open with @ssh.Identity::of on the private half. Ed25519 and RSA are both accepted. kind() reports the algorithm; fingerprint() returns age's short SSH fingerprint.
fn ssh_example() -> Unit raise @spec.Refused {
let recipient = @ssh.Recipient::of(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFi1otykT1YoBR3We0Xf/eNmdx4W32NHFBqQo+b4DqHM moonseal guide",
) catch { _ => abort("not an SSH key") }
println("kind: " + recipient.kind())
println("fingerprint: " + recipient.fingerprint())
let seal_rand = @moonseed.Replay::new(Bytes::make(64, 0x21))
let file = @moonseal.seal([recipient], b"the quick brown fox\n", random=seal_rand) catch {
_ => abort("cannot seal")
}
let priv_text : String =
"-----BEGIN OPENSSH PRIVATE KEY-----\n" +
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" +
"QyNTUxOQAAACBYtaLcpE9WKAUd1ntF3/3jZnceFt9jRxQakKPm+A6hzAAAAJhGzMSTRszE\n" +
"kwAAAAtzc2gtZWQyNTUxOQAAACBYtaLcpE9WKAUd1ntF3/3jZnceFt9jRxQakKPm+A6hzA\n" +
"AAAEAPBk/Wd8ZajsZ4fKz8ITJwWncXCrmdlyQVg4nGcvOHRVi1otykT1YoBR3We0Xf/eNm\n" +
"dx4W32NHFBqQo+b4DqHMAAAADm1vb25zZWFsIGd1aWRlAQIDBAUGBw==\n" +
"-----END OPENSSH PRIVATE KEY-----"
let identity = @ssh.Identity::of(priv_text)
println("opened: " + @utf8.decode_lossy(@moonseal.open([identity], file)))
}--- ssh --- kind: ssh-ed25519 fingerprint: f2MncQ opened: the quick brown fox
@mlkem768x25519 pairs X25519 with ML-KEM-768. A sealed file stays openable by a classical reader, but resists a future quantum adversary. The API is identical to the plain key pair: generate, hand out .public(), open with the identity.
fn post_quantum() -> Unit raise @spec.Refused {
let kp = @moonseed.Replay::new(Bytes::make(128, 0x7a))
let after = @mlkem768x25519.Identity::generate(random=kp) catch { _ => abort("no") }
println(after.code()) // AGE-SECRET-KEY-PQ-…
let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21))
let file = @moonseal.seal([after.public()], b"still secret\n", random=seal_rand) catch {
_ => abort("no")
}
println("opened: " + @utf8.decode_lossy(@moonseal.open([after], file)))
}--- post-quantum --- AGE-SECRET-KEY-PQ-10FA857N60FA857N60FA857N60FA857N60FA857N60FA857N60FAQCETL9H opened: still secret
A tagged recipient names a key by a short tag instead of a full public key, so the sealed file does not reveal which key opens it. @p256tag uses a P-256 point under the tag. Generate the identity, publish .public() (the age1tag1… string), open with the identity.
fn tagged() -> Unit raise @spec.Refused {
let kp = @moonseed.Replay::new(Bytes::make(128, 0x99))
let device = @p256tag.Identity::generate(random=kp) catch { _ => abort("no") }
println(device.public().code()) // age1tag1…
let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21))
let file = @moonseal.seal([device.public()], b"for the key on the desk\n", random=seal_rand) catch {
_ => abort("no")
}
println("opened: " + @utf8.decode_lossy(@moonseal.open([device], file)))
}--- tagged --- age1tag1q0wuyawkyvqmwdp5lwhemmeugtz06kx0na53rswl7v64we6t5rv5vp064z0 opened: for the key on the desk
@mlkem768p256tag combines the tagged P-256 scheme with ML-KEM-768: the key stays unnamed in the header and the file is quantum-resistant. The public code is long because it embeds the ML-KEM ciphertext; the workflow is unchanged.
fn tagged_post_quantum() -> Unit raise @spec.Refused {
let kp = @moonseed.Replay::new(Bytes::make(128, 0xbb))
let device = @mlkem768p256tag.Identity::generate(random=kp) catch { _ => abort("no") }
println(device.public().code()) // age1tagpq1… (long)
let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21))
let file = @moonseal.seal([device.public()], b"for the key on the desk\n", random=seal_rand) catch {
_ => abort("no")
}
println("opened: " + @utf8.decode_lossy(@moonseal.open([device], file)))
}--- tagged post-quantum --- age1tagpq10yd39yeezsha20akfscgfmr52wppyscentxqppahvxp8y5wf3an22dlhqn6qx2kvcpgmtrrvnexfnuwq283yzdn3yv4jyf5hft9f9gm84xj6slus0xw76fjfg6rgle26hc0t3nahvjge46dt4cvmls6yynzush2kmd3s62pgazyr3hd3dz7uwz5ypfhtzf69j3tjdlx44999xfsmgvsqhrrvu5c3a9pmy4q74sfvnwhqga46qdac0rvqtkzkkqucmsq07lzngm6j87rvzrzm9fkssaw38ssv66nqk3nkvpsq28rwwgvznk2m84ae0plerafgzc7w7k6m5fu06n4empdjcznt8nemve74df3ygz4j4ry4g4zynqsnwjr3a63cgup9uthtvkryxqjfupnntvyh6lar788f2tdsclgrk5vrl264vcn4uvmrzjsuzwwa6jttgj5zxsurj3nekyrrtgpvctxz2jwgjfvcpxdr9rvl53ajyctcthxd4h5f6s7f0uj53fpczuctj4wf5h6hcy9n3w6qjf245f5qyengwdnc7743g0ze49aaqkpljtq8jhquqdshz8f6ghk9zerrr3ead3prftj3wv0eva69n36vys9hl3z2pf6cd3dzgm4vkqg07gpk223n4umtgzfznrydntrrmqyfuqqkdv6hvpvv0nmzct9a2smymkakgkm3dhwq5820l8yxjamqng3sfyxpeyjvklk07wrtdujjmlgryyaey4ut5gvx766slfgvq2uf82ptqvj42mksqvnmh8qqj64t62yjn6u2xv3mt34upwyy28zjc07md7ukv5mpcgufqdfdujselmm2ypeug4d7mxwakud54cdx0svge2utktelzst2dxqpmfmna90zwgkus6qkdvylt09cdxrr9n84pgue3wcs6uh3g7spg76el7ettqwa5dsqhktu4fml5anmpcg5ztlejpfmqucz6q3k99ecnnvmktrarpx4s9yuy4amfw46v9d5s5m7939e4240zgsjwsaxs6p54ytqgv88jf3r2tzhkgujezz3rs04xqs5lyuz04vk5acyk3n8gl74kvl2v0rrn56l4ac8k43sx6wyhy6y6e2lg4g2pv3qla0ng09atsd5xp5y8ygpak25c7l65ghasqvc6yhwlwpu2my9806uvk4ctxvuqjczy7v3zquxwuzpvr7rhrcxghprtkp2p6wxnk7x86zw8w4l4jc030p05myna06gt36ukpm0rv9ev63x0v5zdn8yw9plzzsqrkxl2t9xetfplsy3ek40we3czd6qc9u42cfstyrejglrtjcfwksdwkzjkx3m26xyz2cakc5mqusxg5dg4gz3xuz8clhxe9xscx8kfpev6lunqdzjdwzuvklav4h463faj62srn0r8wnzdzfcgf5e30yjfufvwdf6j389fvcalw7kzfuzundsksg32yvwc7lkpt8g9vej3s9kmewyq6mayj5mdpxdskc9m9mpedw4cm2hc5pxz2glv9trepgv3zfzp6y7ezu4as8u2xavffxy5sl689dtvkaxzufm6vmx4l3pc89u4d8xjj0qqvc3c22e25sgv9vu9judspqsws6ctzjqg0u4ghzfps6l8zlv0ff63z2mqs23mz8uw7jhv4s0kum6udr5l0gefgvh2wjk8w38v528k2j52xet7z32qkj85qtvkqjxcqpg287yf4gxhla4vjucpwlt7cred3a684yy9t3h8qvh66mnekjc2jpelkmx2frzvsukj4f8mzj66mdmwscnp2e550xe7dq8m93y8h3vk9cpsqjty6tgft9p2ux242aa4fkklmmzhnx9k440zqeu8czz8ryalkt38a4qnunre7gyna950a2cthc3p76584pwqn6e8mel3shpz8v52f2hvt6kqy0tx388ssv34n4xuhcyzw4ea0cfvjflase3gpk5k63e5p3n8pq7nza5m5gdjls6h opened: for the key on the desk
When the payload does not fit in memory, seal and open through a source and a sink. @moonseal.BytesSource and @moonseal.BytesSink are in-memory stand-ins; replace them with a file or network stream in production. The format is byte-identical to the buffered path, in 64 KiB chunks.
fn stream_example() -> Unit raise @spec.Refused {
let sink = @moonseal.BytesSink::new()
let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21))
@moonseal.seal_stream(
sink,
[@scrypt.Recipient::new("correct horse battery staple", log_n=10)],
@moonseal.BytesSource::new(b"the quick brown fox\n"),
random=seal_rand,
) catch { _ => abort("no") }
let file = sink.bytes()
let open_sink = @moonseal.BytesSink::new()
@moonseal.open_stream(
open_sink,
[@scrypt.Identity::new("correct horse battery staple")],
@moonseal.BytesSource::new(file),
) catch { _ => abort("no") }
println("opened: " + @utf8.decode_lossy(open_sink.bytes()))
}--- stream --- opened: the quick brown fox
When the ciphertext must travel through a text-only channel (chat, email, a paste), seal_armored wraps the age file in a PEM-style envelope. open_armored reads it back. The body is the same age v1 stream, base64-encoded.
fn armor_example() -> Unit raise @spec.Refused {
let seal_rand = @moonseed.Replay::new(Bytes::make(128, 0x21))
let text = @moonseal.seal_armored(
[@scrypt.Recipient::new("correct horse battery staple", log_n=10)],
b"the quick brown fox\n",
random=seal_rand,
) catch { _ => abort("no") }
println(text) // the armored age file
}--- armor --- -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBJU0VoSVNFaElTRWhJU0Vo SVNFaElRIDEwClpWTThtc0tTekY3cnZJMklrZWRhUG43SHhFVHRiRzZoMG9FbVky NlJ2TzgKLS0tIDlwL09yaDRmUWlaaklKcUp0MGlnMzBWNWdwZHlBSTBLdHFOWnhV ZHE0N2MKISEhISEhISEhISEhISEhISknpBd0rIwD4A1Z7Lby9lt2n1S4ggaR1wSF OddHwrwSvDsqhQ== -----END AGE ENCRYPTED FILE-----
A recipient is two traits: @spec.Recipient wraps the file key into a stanza, and @spec.Identity unwraps it. Implement both and the type drops into any seal / open call. The example below is a transparent stand-in that hands the file key back verbatim — a real type would wrap it under a key it owns.
struct Example {}
impl @spec.Recipient for Example with wrap(self, file_key~, random~) {
ignore(self)
ignore(random)
@spec.Stanza::new(kind="example.com/plain", args=[], body=file_key.to_owned())
}
impl @spec.Identity for Example with unwrap(self, stanza~) {
ignore(self)
if stanza.kind == "example.com/plain" {
return Some(stanza.body)
}
None
}
fn custom_example() -> Unit raise @spec.Refused {
let file = @moonseal.seal(
[Example::{}],
b"the quick brown fox\n",
random=@moonseed.Replay::new(Bytes::make(64, 0x21)),
) catch { _ => abort("no") }
println("opened: " + @utf8.decode_lossy(@moonseal.open([Example::{}], file)))
}--- custom recipient --- opened: the quick brown fox
age, rage, and any other conforming implementation — and their files open here. The CI exchanges a sealed file with age and rage on every run.