@naughtbot/e2ee-payloads 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.test.ts CHANGED
@@ -9,10 +9,12 @@ import { describe, it } from "node:test";
9
9
  import type {
10
10
  ApprovalAttestedKeyProof,
11
11
  ApprovalUiV1,
12
+ KeyPurpose,
12
13
  MailboxAgeUnwrapRequestPayloadV1,
13
14
  MailboxBrowserApprovalDecisionBindingV1,
14
15
  MailboxBrowserApprovalRequestPayloadV1,
15
16
  MailboxBrowserApprovalResponsePayloadV1,
17
+ MailboxEnrollRequestPayloadV1,
16
18
  MailboxEnrollResponseApprovedV1,
17
19
  MailboxEnrollResponsePayloadV1,
18
20
  MailboxEnvelopeV1,
@@ -25,6 +27,12 @@ import type {
25
27
  MailboxGpgDecryptResponseSuccessV1,
26
28
  MailboxCaptchaRequestPayloadV1,
27
29
  MailboxCaptchaResponsePayloadV1,
30
+ MailboxKeyInventoryApprovalBindingV1,
31
+ MailboxKeyInventoryEntryV1,
32
+ MailboxKeyInventoryRequestPayloadV1,
33
+ MailboxKeyInventoryResponsePayloadV1,
34
+ MailboxKeyInventoryResponseRejectedV1,
35
+ MailboxKeyInventoryResponseSharedV1,
28
36
  MailboxLinkApprovalPayloadV1,
29
37
  MailboxLinkRejectionPayloadV1,
30
38
  MailboxLinkRequestPayloadV1,
@@ -35,6 +43,8 @@ import type {
35
43
  MailboxSshSignResponsePayloadV1,
36
44
  MailboxSshSignResponseSuccessV1,
37
45
  NaughtBotApprovalBindingV1,
46
+ PublicKeyAlgorithm,
47
+ PublicKeyFormat,
38
48
  } from "./index.ts";
39
49
 
40
50
  const captchaApprovalBindingProfile =
@@ -778,3 +788,298 @@ describe("MailboxEnrollResponsePayloadV1", () => {
778
788
  assert.ok(!JSON.stringify(noFlags).includes("ssh_sk_flags"));
779
789
  });
780
790
  });
791
+
792
+ // Regression test for NaughtBot/e2ee-payloads#36. PKCS#11 protocol keys
793
+ // are dedicated, single-purpose keys minted through the `enroll` envelope,
794
+ // so the KeyPurpose enum must accept `pkcs11` alongside ssh / gpg / age.
795
+ describe("KeyPurpose", () => {
796
+ it("round-trips every purpose including pkcs11", () => {
797
+ const purposes: KeyPurpose[] = ["ssh", "gpg", "age", "pkcs11"];
798
+ for (const purpose of purposes) {
799
+ const request: MailboxEnrollRequestPayloadV1 = { purpose };
800
+ const parsed = JSON.parse(
801
+ JSON.stringify(request),
802
+ ) as MailboxEnrollRequestPayloadV1;
803
+ assert.equal(parsed.purpose, purpose);
804
+ }
805
+ });
806
+
807
+ it("decodes a pkcs11 enroll request from the wire", () => {
808
+ const request = JSON.parse(
809
+ JSON.stringify({
810
+ purpose: "pkcs11",
811
+ label: "YubiKey PKCS#11",
812
+ algorithm: "ed25519",
813
+ }),
814
+ ) as MailboxEnrollRequestPayloadV1;
815
+ assert.equal(request.purpose, "pkcs11");
816
+ });
817
+
818
+ it("rejects unknown purposes at the type level", () => {
819
+ // The KeyPurpose union is a closed set; an unknown value such as
820
+ // "tls" must be a `tsc` error, not a silent accept. Casting through
821
+ // `unknown` documents that the runtime value is intentionally bad.
822
+ const bad = "tls" as unknown as KeyPurpose;
823
+ assert.ok(!(["ssh", "gpg", "age", "pkcs11"] as string[]).includes(bad));
824
+ });
825
+ });
826
+
827
+ interface KeyInventoryVectorsFixture {
828
+ canonical_owner: string;
829
+ approval_binding_format: string;
830
+ empty_key_list_digest: string;
831
+ key_list_digest: string;
832
+ canonical_keys_json: string;
833
+ canonical_binding_json: string;
834
+ binding: MailboxKeyInventoryApprovalBindingV1;
835
+ keys: MailboxKeyInventoryEntryV1[];
836
+ }
837
+
838
+ function loadKeyInventoryVectors(): KeyInventoryVectorsFixture {
839
+ return JSON.parse(
840
+ readFileSync(
841
+ new URL("../../fixtures/key-inventory-v1-vectors.json", import.meta.url),
842
+ "utf8",
843
+ ),
844
+ ) as KeyInventoryVectorsFixture;
845
+ }
846
+
847
+ describe("MailboxKeyInventoryRequestPayloadV1", () => {
848
+ it("round-trips a purpose-filtered request including pkcs11", () => {
849
+ const request: MailboxKeyInventoryRequestPayloadV1 = {
850
+ purposes: ["ssh", "gpg", "age", "pkcs11"],
851
+ source_info: { hostname: "work-laptop" },
852
+ };
853
+ const json = JSON.stringify(request);
854
+ assert.ok(json.includes('"purposes":["ssh","gpg","age","pkcs11"]'));
855
+
856
+ const parsed = JSON.parse(json) as MailboxKeyInventoryRequestPayloadV1;
857
+ assert.equal(parsed.purposes.length, 4);
858
+ assert.equal(parsed.purposes[3], "pkcs11");
859
+ });
860
+ });
861
+
862
+ // Regression coverage for the closed canonical key-format enums. The
863
+ // PublicKeyAlgorithm / PublicKeyFormat unions are closed sets; an unknown
864
+ // member (e.g. "rsa" / "spki_der") must be a `tsc` error rather than a
865
+ // silent accept. Casting through `unknown` documents the intentionally bad
866
+ // runtime value.
867
+ describe("canonical key-format enums", () => {
868
+ it("PublicKeyAlgorithm is a closed set", () => {
869
+ const known: PublicKeyAlgorithm[] = ["ecdsa_p256", "ed25519", "x25519"];
870
+ for (const algorithm of known) {
871
+ assert.ok(["ecdsa_p256", "ed25519", "x25519"].includes(algorithm));
872
+ }
873
+ const bad = "rsa" as unknown as PublicKeyAlgorithm;
874
+ assert.ok(!(known as string[]).includes(bad));
875
+ });
876
+
877
+ it("PublicKeyFormat is a closed set", () => {
878
+ const known: PublicKeyFormat[] = ["sec1_compressed", "raw_32"];
879
+ for (const format of known) {
880
+ assert.ok(["sec1_compressed", "raw_32"].includes(format));
881
+ }
882
+ const bad = "spki_der" as unknown as PublicKeyFormat;
883
+ assert.ok(!(known as string[]).includes(bad));
884
+ });
885
+
886
+ it("rejects public_key_hex inconsistent with algorithm + format", () => {
887
+ // The schema carries the relaxed `^(02|03)?[0-9a-f]{64}$` pattern;
888
+ // receivers MUST additionally enforce the per-algorithm layout. This
889
+ // mirrors the Go validateCanonicalKeyMaterial helper.
890
+ const ok: MailboxKeyInventoryEntryV1 = {
891
+ purpose: "ssh",
892
+ id: "ssh-key-0001",
893
+ device_key_id: "se-handle-ssh-0001",
894
+ algorithm: "ecdsa_p256",
895
+ public_key_format: "sec1_compressed",
896
+ public_key_hex:
897
+ "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
898
+ };
899
+ assert.ok(canonicalKeyMaterialValid(ok));
900
+
901
+ // ecdsa_p256 with a 64-hex (raw_32-length) value is inconsistent.
902
+ assert.ok(
903
+ !canonicalKeyMaterialValid({
904
+ ...ok,
905
+ public_key_hex:
906
+ "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
907
+ }),
908
+ );
909
+ // ed25519 with a 66-hex (sec1-length) value is inconsistent.
910
+ assert.ok(
911
+ !canonicalKeyMaterialValid({
912
+ ...ok,
913
+ algorithm: "ed25519",
914
+ public_key_format: "raw_32",
915
+ }),
916
+ );
917
+ // algorithm/format pairing mismatch.
918
+ assert.ok(
919
+ !canonicalKeyMaterialValid({ ...ok, public_key_format: "raw_32" }),
920
+ );
921
+ });
922
+ });
923
+
924
+ function canonicalKeyMaterialValid(entry: MailboxKeyInventoryEntryV1): boolean {
925
+ const hex = entry.public_key_hex;
926
+ switch (entry.algorithm) {
927
+ case "ecdsa_p256":
928
+ return (
929
+ entry.public_key_format === "sec1_compressed" &&
930
+ hex.length === 66 &&
931
+ (hex.startsWith("02") || hex.startsWith("03"))
932
+ );
933
+ case "ed25519":
934
+ case "x25519":
935
+ return entry.public_key_format === "raw_32" && hex.length === 64;
936
+ default:
937
+ return false;
938
+ }
939
+ }
940
+
941
+ describe("MailboxKeyInventoryResponsePayloadV1", () => {
942
+ it("discriminates shared vs rejected via status", () => {
943
+ const fixture = loadKeyInventoryVectors();
944
+
945
+ const shared: MailboxKeyInventoryResponseSharedV1 = {
946
+ status: "shared",
947
+ request_envelope_id: "11111111-2222-4333-8444-555555555555",
948
+ keys: fixture.keys,
949
+ approval_binding: fixture.binding,
950
+ approval_binding_bytes: Buffer.from(
951
+ fixture.canonical_binding_json,
952
+ "utf8",
953
+ ).toString("base64"),
954
+ approval_binding_format: "key-inventory-approval-binding/v1+json",
955
+ approval_signature: Buffer.from(
956
+ "key-inventory-approval-signature",
957
+ "utf8",
958
+ ).toString("base64"),
959
+ approval_signature_algorithm: "ES256",
960
+ approval_proof: approvalProofFixture(),
961
+ };
962
+ const sharedResp = JSON.parse(
963
+ JSON.stringify(shared),
964
+ ) as MailboxKeyInventoryResponsePayloadV1;
965
+ assert.equal(sharedResp.status, "shared");
966
+ if (sharedResp.status === "shared") {
967
+ assert.equal(sharedResp.keys.length, 4);
968
+ assert.equal(
969
+ Buffer.from(sharedResp.approval_binding_bytes, "base64").toString(
970
+ "utf8",
971
+ ),
972
+ fixture.canonical_binding_json,
973
+ );
974
+ }
975
+
976
+ const rejected: MailboxKeyInventoryResponseRejectedV1 = {
977
+ status: "rejected",
978
+ request_envelope_id: "11111111-2222-4333-8444-555555555555",
979
+ error_code: 1,
980
+ error_message: "User rejected the key inventory request",
981
+ };
982
+ const rejectedResp = JSON.parse(
983
+ JSON.stringify(rejected),
984
+ ) as MailboxKeyInventoryResponsePayloadV1;
985
+ assert.equal(rejectedResp.status, "rejected");
986
+ if (rejectedResp.status === "rejected") {
987
+ assert.equal(rejectedResp.error_code, 1);
988
+ }
989
+ });
990
+ });
991
+
992
+ describe("MailboxKeyInventoryEntryV1 protocol metadata", () => {
993
+ it("round-trips SSH / GPG / age / PKCS#11 export metadata", () => {
994
+ const fixture = loadKeyInventoryVectors();
995
+ const [ssh, gpg, age, pkcs11] = fixture.keys;
996
+
997
+ const sshParsed = JSON.parse(
998
+ JSON.stringify(ssh),
999
+ ) as MailboxKeyInventoryEntryV1;
1000
+ assert.equal(sshParsed.purpose, "ssh");
1001
+ assert.equal(
1002
+ sshParsed.ssh?.ssh_key_type,
1003
+ "sk-ecdsa-sha2-nistp256@openssh.com",
1004
+ );
1005
+ assert.ok(sshParsed.ssh?.ssh_fingerprint?.startsWith("SHA256:"));
1006
+ assert.equal(sshParsed.ssh?.ssh_sk_flags, 5);
1007
+
1008
+ const gpgParsed = JSON.parse(
1009
+ JSON.stringify(gpg),
1010
+ ) as MailboxKeyInventoryEntryV1;
1011
+ assert.equal(
1012
+ gpgParsed.gpg?.fingerprint_hex,
1013
+ "A1D597197F5C1DACB3E3BB7862BF2FC536D562FF",
1014
+ );
1015
+ assert.ok(gpgParsed.gpg?.armored_public_key?.includes("PGP PUBLIC KEY"));
1016
+ assert.equal(
1017
+ gpgParsed.gpg?.encryption_public_key_hex,
1018
+ "03b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
1019
+ );
1020
+
1021
+ const ageParsed = JSON.parse(
1022
+ JSON.stringify(age),
1023
+ ) as MailboxKeyInventoryEntryV1;
1024
+ assert.ok(ageParsed.age?.age_recipient?.startsWith("age1nb1"));
1025
+
1026
+ const pkcs11Parsed = JSON.parse(
1027
+ JSON.stringify(pkcs11),
1028
+ ) as MailboxKeyInventoryEntryV1;
1029
+ assert.equal(pkcs11Parsed.pkcs11?.cka_key_type, "CKK_EC");
1030
+ assert.equal(pkcs11Parsed.pkcs11?.can_sign, true);
1031
+ assert.equal(pkcs11Parsed.pkcs11?.can_derive, false);
1032
+ });
1033
+ });
1034
+
1035
+ describe("MailboxKeyInventoryApprovalBindingV1", () => {
1036
+ it("agrees with Go and Swift on the canonical key-inventory vectors", () => {
1037
+ const fixture = loadKeyInventoryVectors();
1038
+ assert.equal(
1039
+ fixture.canonical_owner,
1040
+ "NaughtBot/e2ee-payloads fixtures/key-inventory-v1-vectors.json",
1041
+ );
1042
+ assert.equal(fixture.keys.length, 4);
1043
+
1044
+ // SHA-256 over the canonical keys JSON MUST equal key_list_digest.
1045
+ const digest =
1046
+ "sha256:" +
1047
+ createHash("sha256")
1048
+ .update(fixture.canonical_keys_json, "utf8")
1049
+ .digest("hex");
1050
+ assert.equal(digest, fixture.key_list_digest);
1051
+ assert.equal(fixture.binding.key_list_digest, fixture.key_list_digest);
1052
+
1053
+ // The empty key list digest is SHA-256 of the JSON array "[]".
1054
+ const emptyDigest =
1055
+ "sha256:" + createHash("sha256").update("[]", "utf8").digest("hex");
1056
+ assert.equal(emptyDigest, fixture.empty_key_list_digest);
1057
+
1058
+ // Every fixture entry's canonical key material is internally consistent.
1059
+ for (const entry of fixture.keys) {
1060
+ assert.ok(
1061
+ canonicalKeyMaterialValid(entry),
1062
+ `fixture key ${entry.purpose} failed canonical validation`,
1063
+ );
1064
+ }
1065
+
1066
+ // Re-stringifying the fixture binding object (whose properties are
1067
+ // already in lexicographic order) reproduces the canonical signed bytes.
1068
+ assert.equal(
1069
+ JSON.stringify(fixture.binding),
1070
+ fixture.canonical_binding_json,
1071
+ );
1072
+
1073
+ const bindingParsed = JSON.parse(
1074
+ JSON.stringify(fixture.binding),
1075
+ ) as MailboxKeyInventoryApprovalBindingV1;
1076
+ assert.equal(bindingParsed.version, "key-inventory-approval-binding/v1");
1077
+ assert.equal(bindingParsed.request_envelope_type, "key_inventory_request");
1078
+ assert.deepEqual(bindingParsed.requested_purposes, [
1079
+ "ssh",
1080
+ "gpg",
1081
+ "age",
1082
+ "pkcs11",
1083
+ ]);
1084
+ });
1085
+ });
package/src/index.ts CHANGED
@@ -58,6 +58,12 @@ export type MailboxPkcs11DeriveRequestPayloadV1 = components["schemas"]["Mailbox
58
58
  export type MailboxPkcs11DeriveResponsePayloadV1 = components["schemas"]["MailboxPkcs11DeriveResponsePayloadV1"];
59
59
  export type MailboxPkcs11DeriveResponseSuccessV1 = components["schemas"]["MailboxPkcs11DeriveResponseSuccessV1"];
60
60
  export type MailboxPkcs11DeriveResponseFailureV1 = components["schemas"]["MailboxPkcs11DeriveResponseFailureV1"];
61
+ export type KeyPurpose = components["schemas"]["KeyPurpose"];
62
+ export type PublicKeyAlgorithm = components["schemas"]["PublicKeyAlgorithm"];
63
+ export type PublicKeyFormat = components["schemas"]["PublicKeyFormat"];
64
+ export type Sec1CompressedPublicKeyHex = components["schemas"]["Sec1CompressedPublicKeyHex"];
65
+ export type Raw32PublicKeyHex = components["schemas"]["Raw32PublicKeyHex"];
66
+ export type CanonicalPublicKeyHex = components["schemas"]["CanonicalPublicKeyHex"];
61
67
  export type MailboxEnrollRequestPayloadV1 = components["schemas"]["MailboxEnrollRequestPayloadV1"];
62
68
  export type MailboxEnrollResponsePayloadV1 = components["schemas"]["MailboxEnrollResponsePayloadV1"];
63
69
  export type MailboxEnrollResponseApprovedV1 = components["schemas"]["MailboxEnrollResponseApprovedV1"];
@@ -85,3 +91,14 @@ export type MailboxFirstPartyPrivilegedActionRequestV1 = components["schemas"]["
85
91
  export type MailboxFirstPartyRequestPayloadV1 = components["schemas"]["MailboxFirstPartyRequestPayloadV1"];
86
92
  export type MailboxFirstPartyPrivilegedActionDecisionBindingV1 = components["schemas"]["MailboxFirstPartyPrivilegedActionDecisionBindingV1"];
87
93
  export type MailboxFirstPartyResponsePayloadV1 = components["schemas"]["MailboxFirstPartyResponsePayloadV1"];
94
+ export type MailboxKeyInventoryRequestPayloadV1 = components["schemas"]["MailboxKeyInventoryRequestPayloadV1"];
95
+ export type MailboxKeyInventoryApprovalBindingFormat = components["schemas"]["MailboxKeyInventoryApprovalBindingFormat"];
96
+ export type MailboxKeyInventoryEntryV1 = components["schemas"]["MailboxKeyInventoryEntryV1"];
97
+ export type KeyInventorySshMetadataV1 = components["schemas"]["KeyInventorySshMetadataV1"];
98
+ export type KeyInventoryGpgMetadataV1 = components["schemas"]["KeyInventoryGpgMetadataV1"];
99
+ export type KeyInventoryAgeMetadataV1 = components["schemas"]["KeyInventoryAgeMetadataV1"];
100
+ export type KeyInventoryPkcs11MetadataV1 = components["schemas"]["KeyInventoryPkcs11MetadataV1"];
101
+ export type MailboxKeyInventoryResponseSharedV1 = components["schemas"]["MailboxKeyInventoryResponseSharedV1"];
102
+ export type MailboxKeyInventoryResponseRejectedV1 = components["schemas"]["MailboxKeyInventoryResponseRejectedV1"];
103
+ export type MailboxKeyInventoryResponsePayloadV1 = components["schemas"]["MailboxKeyInventoryResponsePayloadV1"];
104
+ export type MailboxKeyInventoryApprovalBindingV1 = components["schemas"]["MailboxKeyInventoryApprovalBindingV1"];