moss-sdk 0.1.0__py3-none-any.whl
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.
- moss/__init__.py +28 -0
- moss/__main__.py +4 -0
- moss/canonical.py +29 -0
- moss/cli.py +109 -0
- moss/crypto.py +29 -0
- moss/encoding.py +14 -0
- moss/envelope.py +41 -0
- moss/errors.py +38 -0
- moss/keystore.py +117 -0
- moss/sequence.py +30 -0
- moss/subject.py +183 -0
- moss_sdk-0.1.0.dist-info/METADATA +166 -0
- moss_sdk-0.1.0.dist-info/RECORD +15 -0
- moss_sdk-0.1.0.dist-info/WHEEL +4 -0
- moss_sdk-0.1.0.dist-info/entry_points.txt +2 -0
moss/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .subject import Subject, VerifyResult
|
|
2
|
+
from .envelope import Envelope
|
|
3
|
+
from .errors import (
|
|
4
|
+
MossError,
|
|
5
|
+
InvalidSubject,
|
|
6
|
+
KeyNotFound,
|
|
7
|
+
InvalidEnvelope,
|
|
8
|
+
InvalidSignature,
|
|
9
|
+
PayloadMismatch,
|
|
10
|
+
ReplayDetected,
|
|
11
|
+
DecryptionFailed,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Subject",
|
|
18
|
+
"Envelope",
|
|
19
|
+
"VerifyResult",
|
|
20
|
+
"MossError",
|
|
21
|
+
"InvalidSubject",
|
|
22
|
+
"KeyNotFound",
|
|
23
|
+
"InvalidEnvelope",
|
|
24
|
+
"InvalidSignature",
|
|
25
|
+
"PayloadMismatch",
|
|
26
|
+
"ReplayDetected",
|
|
27
|
+
"DecryptionFailed",
|
|
28
|
+
]
|
moss/__main__.py
ADDED
moss/canonical.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import hashlib
|
|
3
|
+
|
|
4
|
+
from .encoding import b64url_encode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def cjjson(obj: dict) -> bytes:
|
|
8
|
+
"""
|
|
9
|
+
RFC 8785 JSON Canonicalization Scheme.
|
|
10
|
+
- Sort keys lexicographically (Unicode code point order)
|
|
11
|
+
- No whitespace
|
|
12
|
+
- UTF-8 encoding
|
|
13
|
+
"""
|
|
14
|
+
return json.dumps(
|
|
15
|
+
obj,
|
|
16
|
+
separators=(',', ':'),
|
|
17
|
+
sort_keys=True,
|
|
18
|
+
ensure_ascii=False
|
|
19
|
+
).encode('utf-8')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sha256_b64url(data: bytes) -> str:
|
|
23
|
+
"""SHA-256 hash, returned as base64url (43 chars)."""
|
|
24
|
+
return b64url_encode(hashlib.sha256(data).digest())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def payload_hash(payload: dict) -> str:
|
|
28
|
+
"""Compute payload_hash per moss-0001."""
|
|
29
|
+
return sha256_b64url(cjjson(payload))
|
moss/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from . import Subject, Envelope
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(prog="moss", description="MOSS CLI - Sign and verify agent outputs")
|
|
9
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
10
|
+
|
|
11
|
+
# moss subject create <subject>
|
|
12
|
+
sub_parser = subparsers.add_parser("subject", help="Manage subjects")
|
|
13
|
+
sub_sub = sub_parser.add_subparsers(dest="subcommand")
|
|
14
|
+
create_parser = sub_sub.add_parser("create", help="Create new subject")
|
|
15
|
+
create_parser.add_argument("subject", help="Subject ID (moss:namespace:name)")
|
|
16
|
+
|
|
17
|
+
# moss sign <subject> <payload_file>
|
|
18
|
+
sign_parser = subparsers.add_parser("sign", help="Sign a payload")
|
|
19
|
+
sign_parser.add_argument("subject", help="Subject to sign as")
|
|
20
|
+
sign_parser.add_argument("payload_file", help="JSON payload file (- for stdin)")
|
|
21
|
+
|
|
22
|
+
# moss verify <payload_file> <envelope_file>
|
|
23
|
+
verify_parser = subparsers.add_parser("verify", help="Verify an envelope")
|
|
24
|
+
verify_parser.add_argument("payload_file", help="JSON payload file")
|
|
25
|
+
verify_parser.add_argument("envelope_file", help="Envelope JSON file")
|
|
26
|
+
verify_parser.add_argument("--no-replay-check", action="store_true", help="Skip replay detection")
|
|
27
|
+
|
|
28
|
+
# moss diff <env1> <env2>
|
|
29
|
+
diff_parser = subparsers.add_parser("diff", help="Compare two envelopes")
|
|
30
|
+
diff_parser.add_argument("envelope1", help="First envelope file")
|
|
31
|
+
diff_parser.add_argument("envelope2", help="Second envelope file")
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
if args.command == "subject" and args.subcommand == "create":
|
|
37
|
+
subject = Subject.create(args.subject)
|
|
38
|
+
print(f"Created: {args.subject}")
|
|
39
|
+
print(f" Public key: {subject.public_key.hex()[:32]}...")
|
|
40
|
+
sys.exit(0)
|
|
41
|
+
|
|
42
|
+
elif args.command == "sign":
|
|
43
|
+
subject = Subject.load(args.subject)
|
|
44
|
+
if args.payload_file == "-":
|
|
45
|
+
payload = json.load(sys.stdin)
|
|
46
|
+
else:
|
|
47
|
+
with open(args.payload_file) as f:
|
|
48
|
+
payload = json.load(f)
|
|
49
|
+
envelope = subject.sign(payload)
|
|
50
|
+
print(json.dumps(envelope.to_dict(), indent=2))
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
elif args.command == "verify":
|
|
54
|
+
with open(args.payload_file) as f:
|
|
55
|
+
payload = json.load(f)
|
|
56
|
+
with open(args.envelope_file) as f:
|
|
57
|
+
envelope_data = json.load(f)
|
|
58
|
+
envelope = Envelope.from_dict(envelope_data)
|
|
59
|
+
result = Subject.verify(
|
|
60
|
+
envelope,
|
|
61
|
+
payload,
|
|
62
|
+
check_replay=not args.no_replay_check
|
|
63
|
+
)
|
|
64
|
+
if result.valid:
|
|
65
|
+
print(f"Valid signature from {result.subject}")
|
|
66
|
+
sys.exit(0)
|
|
67
|
+
else:
|
|
68
|
+
print(f"Invalid: {result.reason} [{result.error_code}]")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
elif args.command == "diff":
|
|
72
|
+
with open(args.envelope1) as f:
|
|
73
|
+
env1 = json.load(f)
|
|
74
|
+
with open(args.envelope2) as f:
|
|
75
|
+
env2 = json.load(f)
|
|
76
|
+
|
|
77
|
+
diffs = []
|
|
78
|
+
all_keys = set(env1.keys()) | set(env2.keys())
|
|
79
|
+
for key in sorted(all_keys):
|
|
80
|
+
v1 = env1.get(key)
|
|
81
|
+
v2 = env2.get(key)
|
|
82
|
+
if v1 != v2:
|
|
83
|
+
diffs.append(f" {key}: {v1} -> {v2}")
|
|
84
|
+
|
|
85
|
+
if diffs:
|
|
86
|
+
print("Differences:")
|
|
87
|
+
print("\n".join(diffs))
|
|
88
|
+
else:
|
|
89
|
+
print("Envelopes are identical")
|
|
90
|
+
sys.exit(0)
|
|
91
|
+
|
|
92
|
+
else:
|
|
93
|
+
parser.print_help()
|
|
94
|
+
sys.exit(3)
|
|
95
|
+
|
|
96
|
+
except FileNotFoundError as e:
|
|
97
|
+
print(f"File not found: {e}", file=sys.stderr)
|
|
98
|
+
sys.exit(2)
|
|
99
|
+
except json.JSONDecodeError as e:
|
|
100
|
+
print(f"Invalid JSON: {e}", file=sys.stderr)
|
|
101
|
+
sys.exit(2)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
code = getattr(e, 'code', 'MOSS_ERR_000')
|
|
104
|
+
print(f"{e} [{code}]", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
moss/crypto.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dilithium_py.dilithium import Dilithium2
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MossCrypto:
|
|
5
|
+
ALG = "ML-DSA-44"
|
|
6
|
+
EXPECTED_PK_LEN = 1312
|
|
7
|
+
EXPECTED_SIG_LEN = 2420
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def keygen(cls) -> tuple[bytes, bytes]:
|
|
11
|
+
"""Generate (public_key, secret_key) pair."""
|
|
12
|
+
pk, sk = Dilithium2.keygen()
|
|
13
|
+
assert len(pk) == cls.EXPECTED_PK_LEN, f"Bad pk length: {len(pk)}"
|
|
14
|
+
return pk, sk
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def sign(cls, secret_key: bytes, message: bytes) -> bytes:
|
|
18
|
+
"""Sign message with secret key."""
|
|
19
|
+
sig = Dilithium2.sign(secret_key, message)
|
|
20
|
+
assert len(sig) == cls.EXPECTED_SIG_LEN, f"Bad sig length: {len(sig)}"
|
|
21
|
+
return sig
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def verify(cls, public_key: bytes, message: bytes, signature: bytes) -> bool:
|
|
25
|
+
"""Verify signature. Returns True/False, never raises."""
|
|
26
|
+
try:
|
|
27
|
+
return Dilithium2.verify(public_key, message, signature)
|
|
28
|
+
except Exception:
|
|
29
|
+
return False
|
moss/encoding.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def b64url_encode(data: bytes) -> str:
|
|
5
|
+
"""base64url encode without padding."""
|
|
6
|
+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def b64url_decode(s: str) -> bytes:
|
|
10
|
+
"""base64url decode, handle missing padding."""
|
|
11
|
+
padding = 4 - (len(s) % 4)
|
|
12
|
+
if padding != 4:
|
|
13
|
+
s += '=' * padding
|
|
14
|
+
return base64.urlsafe_b64decode(s)
|
moss/envelope.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Envelope:
|
|
6
|
+
spec: str
|
|
7
|
+
version: int
|
|
8
|
+
alg: str
|
|
9
|
+
subject: str
|
|
10
|
+
key_version: int
|
|
11
|
+
seq: int
|
|
12
|
+
issued_at: int
|
|
13
|
+
payload_hash: str
|
|
14
|
+
signature: str
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"spec": self.spec,
|
|
19
|
+
"version": self.version,
|
|
20
|
+
"alg": self.alg,
|
|
21
|
+
"subject": self.subject,
|
|
22
|
+
"key_version": self.key_version,
|
|
23
|
+
"seq": self.seq,
|
|
24
|
+
"issued_at": self.issued_at,
|
|
25
|
+
"payload_hash": self.payload_hash,
|
|
26
|
+
"signature": self.signature
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, d: dict) -> "Envelope":
|
|
31
|
+
return cls(
|
|
32
|
+
spec=d["spec"],
|
|
33
|
+
version=d["version"],
|
|
34
|
+
alg=d["alg"],
|
|
35
|
+
subject=d["subject"],
|
|
36
|
+
key_version=d["key_version"],
|
|
37
|
+
seq=d["seq"],
|
|
38
|
+
issued_at=d["issued_at"],
|
|
39
|
+
payload_hash=d["payload_hash"],
|
|
40
|
+
signature=d["signature"]
|
|
41
|
+
)
|
moss/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class MossError(Exception):
|
|
2
|
+
"""Base exception for MOSS errors."""
|
|
3
|
+
code = "MOSS_ERR_000"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidSubject(MossError):
|
|
7
|
+
"""Invalid subject format."""
|
|
8
|
+
code = "MOSS_ERR_001"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KeyNotFound(MossError):
|
|
12
|
+
"""No keys found for subject."""
|
|
13
|
+
code = "MOSS_ERR_002"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidEnvelope(MossError):
|
|
17
|
+
"""Malformed envelope."""
|
|
18
|
+
code = "MOSS_ERR_003"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidSignature(MossError):
|
|
22
|
+
"""Signature verification failed."""
|
|
23
|
+
code = "MOSS_ERR_004"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PayloadMismatch(MossError):
|
|
27
|
+
"""Payload hash does not match envelope."""
|
|
28
|
+
code = "MOSS_ERR_005"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReplayDetected(MossError):
|
|
32
|
+
"""Sequence number already seen (replay attack)."""
|
|
33
|
+
code = "MOSS_ERR_006"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DecryptionFailed(MossError):
|
|
37
|
+
"""Failed to decrypt keystore (wrong passphrase?)."""
|
|
38
|
+
code = "MOSS_ERR_007"
|
moss/keystore.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
6
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
7
|
+
|
|
8
|
+
from .encoding import b64url_encode, b64url_decode
|
|
9
|
+
from .errors import KeyNotFound, DecryptionFailed, InvalidSubject
|
|
10
|
+
|
|
11
|
+
MOSS_DIR = Path.home() / ".moss"
|
|
12
|
+
KEYS_DIR = MOSS_DIR / "keys"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_passphrase() -> bytes:
|
|
16
|
+
"""Get passphrase from env or use default (for dev)."""
|
|
17
|
+
pp = os.environ.get("MOSS_KEY_PASSPHRASE", "moss-dev-passphrase")
|
|
18
|
+
return pp.encode('utf-8')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _derive_key(salt: bytes) -> bytes:
|
|
22
|
+
"""Derive 256-bit key from passphrase using Scrypt."""
|
|
23
|
+
kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1)
|
|
24
|
+
return kdf.derive(_get_passphrase())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _encrypt(data: bytes, salt: bytes) -> tuple[bytes, bytes]:
|
|
28
|
+
"""Encrypt data with AES-256-GCM. Returns (nonce, ciphertext)."""
|
|
29
|
+
key = _derive_key(salt)
|
|
30
|
+
nonce = os.urandom(12)
|
|
31
|
+
aesgcm = AESGCM(key)
|
|
32
|
+
ct = aesgcm.encrypt(nonce, data, None)
|
|
33
|
+
return nonce, ct
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _decrypt(nonce: bytes, ciphertext: bytes, salt: bytes) -> bytes:
|
|
37
|
+
"""Decrypt data with AES-256-GCM."""
|
|
38
|
+
key = _derive_key(salt)
|
|
39
|
+
aesgcm = AESGCM(key)
|
|
40
|
+
try:
|
|
41
|
+
return aesgcm.decrypt(nonce, ciphertext, None)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise DecryptionFailed(f"Failed to decrypt key: {e}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_key_path(subject: str) -> Path:
|
|
47
|
+
"""Convert subject to file path: moss:ns:name -> ns/name.json"""
|
|
48
|
+
parts = subject.split(":")
|
|
49
|
+
if len(parts) != 3 or parts[0] != "moss":
|
|
50
|
+
raise InvalidSubject(f"Invalid subject format: {subject}")
|
|
51
|
+
namespace, name = parts[1], parts[2]
|
|
52
|
+
return KEYS_DIR / namespace / f"{name}.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_keys(subject: str, public_key: bytes, secret_key: bytes, key_version: int = 1):
|
|
56
|
+
"""Save encrypted key pair to local storage."""
|
|
57
|
+
path = get_key_path(subject)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
salt = os.urandom(16)
|
|
61
|
+
nonce, encrypted_sk = _encrypt(secret_key, salt)
|
|
62
|
+
|
|
63
|
+
data = {
|
|
64
|
+
"version": 1,
|
|
65
|
+
"subject": subject,
|
|
66
|
+
"key_version": key_version,
|
|
67
|
+
"alg": "ML-DSA-44",
|
|
68
|
+
"public_key": b64url_encode(public_key),
|
|
69
|
+
"encrypted_secret_key": b64url_encode(encrypted_sk),
|
|
70
|
+
"salt": b64url_encode(salt),
|
|
71
|
+
"nonce": b64url_encode(nonce),
|
|
72
|
+
"seq": 0,
|
|
73
|
+
"created_at": int(time.time())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
path.write_text(json.dumps(data, indent=2))
|
|
77
|
+
os.chmod(path, 0o600)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_keys(subject: str) -> dict:
|
|
81
|
+
"""Load and decrypt key data from local storage."""
|
|
82
|
+
path = get_key_path(subject)
|
|
83
|
+
if not path.exists():
|
|
84
|
+
raise KeyNotFound(f"No keys found for {subject}")
|
|
85
|
+
|
|
86
|
+
data = json.loads(path.read_text())
|
|
87
|
+
|
|
88
|
+
salt = b64url_decode(data["salt"])
|
|
89
|
+
nonce = b64url_decode(data["nonce"])
|
|
90
|
+
encrypted_sk = b64url_decode(data["encrypted_secret_key"])
|
|
91
|
+
secret_key = _decrypt(nonce, encrypted_sk, salt)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"subject": data["subject"],
|
|
95
|
+
"key_version": data["key_version"],
|
|
96
|
+
"public_key": b64url_decode(data["public_key"]),
|
|
97
|
+
"secret_key": secret_key,
|
|
98
|
+
"seq": data["seq"]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def increment_seq(subject: str) -> int:
|
|
103
|
+
"""Atomically increment and return next sequence number."""
|
|
104
|
+
path = get_key_path(subject)
|
|
105
|
+
data = json.loads(path.read_text())
|
|
106
|
+
data["seq"] += 1
|
|
107
|
+
path.write_text(json.dumps(data, indent=2))
|
|
108
|
+
return data["seq"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_public_key(subject: str) -> bytes:
|
|
112
|
+
"""Get just the public key (no decryption needed)."""
|
|
113
|
+
path = get_key_path(subject)
|
|
114
|
+
if not path.exists():
|
|
115
|
+
raise KeyNotFound(f"No keys found for {subject}")
|
|
116
|
+
data = json.loads(path.read_text())
|
|
117
|
+
return b64url_decode(data["public_key"])
|
moss/sequence.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
MOSS_DIR = Path.home() / ".moss"
|
|
5
|
+
SEQ_DIR = MOSS_DIR / "seq"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_last_seen_seq(subject: str, key_version: int) -> int:
|
|
9
|
+
"""Get last seen sequence for (subject, key_version)."""
|
|
10
|
+
path = SEQ_DIR / f"{subject.replace(':', '_')}_{key_version}.json"
|
|
11
|
+
if not path.exists():
|
|
12
|
+
return 0
|
|
13
|
+
data = json.loads(path.read_text())
|
|
14
|
+
return data.get("last_seq", 0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def update_last_seen_seq(subject: str, key_version: int, seq: int):
|
|
18
|
+
"""Update last seen sequence."""
|
|
19
|
+
SEQ_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
path = SEQ_DIR / f"{subject.replace(':', '_')}_{key_version}.json"
|
|
21
|
+
path.write_text(json.dumps({"last_seq": seq}))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_and_update_seq(subject: str, key_version: int, seq: int) -> bool:
|
|
25
|
+
"""Check if seq is valid (greater than last seen) and update. Returns True if valid."""
|
|
26
|
+
last = get_last_seen_seq(subject, key_version)
|
|
27
|
+
if seq <= last:
|
|
28
|
+
return False
|
|
29
|
+
update_last_seen_seq(subject, key_version, seq)
|
|
30
|
+
return True
|
moss/subject.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional, Union
|
|
5
|
+
|
|
6
|
+
from .crypto import MossCrypto
|
|
7
|
+
from .canonical import cjjson, payload_hash
|
|
8
|
+
from .encoding import b64url_encode, b64url_decode
|
|
9
|
+
from .keystore import save_keys, load_keys, increment_seq, get_public_key
|
|
10
|
+
from .sequence import check_and_update_seq
|
|
11
|
+
from .envelope import Envelope
|
|
12
|
+
from .errors import InvalidSubject
|
|
13
|
+
|
|
14
|
+
SPEC = "moss-0001"
|
|
15
|
+
VERSION = 1
|
|
16
|
+
SUBJECT_PATTERN = re.compile(r"^moss:[a-z0-9_-]+:[a-z0-9_-]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VerifyResult:
|
|
21
|
+
valid: bool
|
|
22
|
+
subject: Optional[str] = None
|
|
23
|
+
payload_hash: Optional[str] = None
|
|
24
|
+
reason: Optional[str] = None
|
|
25
|
+
error_code: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Subject:
|
|
29
|
+
def __init__(self, subject: str, secret_key: bytes, public_key: bytes, key_version: int):
|
|
30
|
+
self.subject = subject
|
|
31
|
+
self.secret_key = secret_key
|
|
32
|
+
self.public_key = public_key
|
|
33
|
+
self.key_version = key_version
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def create(cls, subject: str) -> "Subject":
|
|
37
|
+
"""Create a new subject with fresh ML-DSA-44 keypair."""
|
|
38
|
+
if not SUBJECT_PATTERN.match(subject):
|
|
39
|
+
raise InvalidSubject(
|
|
40
|
+
f"Invalid subject format: {subject}. "
|
|
41
|
+
f"Expected: moss:namespace:name (lowercase alphanumeric, hyphens, underscores)"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
pk, sk = MossCrypto.keygen()
|
|
45
|
+
save_keys(subject, pk, sk, key_version=1)
|
|
46
|
+
|
|
47
|
+
return cls(subject=subject, secret_key=sk, public_key=pk, key_version=1)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls, subject: str) -> "Subject":
|
|
51
|
+
"""Load existing subject from local keystore."""
|
|
52
|
+
data = load_keys(subject)
|
|
53
|
+
return cls(
|
|
54
|
+
subject=data["subject"],
|
|
55
|
+
secret_key=data["secret_key"],
|
|
56
|
+
public_key=data["public_key"],
|
|
57
|
+
key_version=data["key_version"]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def sign(self, payload: Any) -> Envelope:
|
|
61
|
+
"""Sign a payload and return an envelope."""
|
|
62
|
+
seq = increment_seq(self.subject)
|
|
63
|
+
issued_at = int(time.time())
|
|
64
|
+
p_hash = payload_hash(payload)
|
|
65
|
+
|
|
66
|
+
signed_bytes_obj = {
|
|
67
|
+
"spec": SPEC,
|
|
68
|
+
"version": VERSION,
|
|
69
|
+
"alg": MossCrypto.ALG,
|
|
70
|
+
"subject": self.subject,
|
|
71
|
+
"key_version": self.key_version,
|
|
72
|
+
"seq": seq,
|
|
73
|
+
"issued_at": issued_at,
|
|
74
|
+
"payload_hash": p_hash
|
|
75
|
+
}
|
|
76
|
+
signed_bytes = cjjson(signed_bytes_obj)
|
|
77
|
+
|
|
78
|
+
signature = MossCrypto.sign(self.secret_key, signed_bytes)
|
|
79
|
+
|
|
80
|
+
return Envelope(
|
|
81
|
+
spec=SPEC,
|
|
82
|
+
version=VERSION,
|
|
83
|
+
alg=MossCrypto.ALG,
|
|
84
|
+
subject=self.subject,
|
|
85
|
+
key_version=self.key_version,
|
|
86
|
+
seq=seq,
|
|
87
|
+
issued_at=issued_at,
|
|
88
|
+
payload_hash=p_hash,
|
|
89
|
+
signature=b64url_encode(signature)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def verify(
|
|
94
|
+
envelope: Union[Envelope, dict],
|
|
95
|
+
payload: Any = None,
|
|
96
|
+
public_key: Optional[bytes] = None,
|
|
97
|
+
check_replay: bool = True
|
|
98
|
+
) -> VerifyResult:
|
|
99
|
+
"""
|
|
100
|
+
Verify an envelope.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
envelope: Envelope object or dict
|
|
104
|
+
payload: Original payload (optional, for hash verification)
|
|
105
|
+
public_key: Public key bytes (optional, will load from keystore if not provided)
|
|
106
|
+
check_replay: Whether to check/update sequence numbers
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
VerifyResult with valid=True/False and details
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(envelope, dict):
|
|
112
|
+
try:
|
|
113
|
+
envelope = Envelope.from_dict(envelope)
|
|
114
|
+
except (KeyError, TypeError) as e:
|
|
115
|
+
return VerifyResult(
|
|
116
|
+
valid=False,
|
|
117
|
+
reason=f"Malformed envelope: {e}",
|
|
118
|
+
error_code="MOSS_ERR_003"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if envelope.spec != SPEC:
|
|
122
|
+
return VerifyResult(
|
|
123
|
+
valid=False,
|
|
124
|
+
reason=f"Unknown spec: {envelope.spec}",
|
|
125
|
+
error_code="MOSS_ERR_003"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if payload is not None:
|
|
129
|
+
computed_hash = payload_hash(payload)
|
|
130
|
+
if computed_hash != envelope.payload_hash:
|
|
131
|
+
return VerifyResult(
|
|
132
|
+
valid=False,
|
|
133
|
+
subject=envelope.subject,
|
|
134
|
+
reason=f"Payload hash mismatch: expected {envelope.payload_hash}, got {computed_hash}",
|
|
135
|
+
error_code="MOSS_ERR_005"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if check_replay:
|
|
139
|
+
if not check_and_update_seq(envelope.subject, envelope.key_version, envelope.seq):
|
|
140
|
+
return VerifyResult(
|
|
141
|
+
valid=False,
|
|
142
|
+
subject=envelope.subject,
|
|
143
|
+
reason=f"Replay detected: seq {envelope.seq} already seen for {envelope.subject}",
|
|
144
|
+
error_code="MOSS_ERR_006"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if public_key is None:
|
|
148
|
+
try:
|
|
149
|
+
public_key = get_public_key(envelope.subject)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return VerifyResult(
|
|
152
|
+
valid=False,
|
|
153
|
+
subject=envelope.subject,
|
|
154
|
+
reason=f"Could not load public key: {e}",
|
|
155
|
+
error_code="MOSS_ERR_002"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
signed_bytes_obj = {
|
|
159
|
+
"spec": envelope.spec,
|
|
160
|
+
"version": envelope.version,
|
|
161
|
+
"alg": envelope.alg,
|
|
162
|
+
"subject": envelope.subject,
|
|
163
|
+
"key_version": envelope.key_version,
|
|
164
|
+
"seq": envelope.seq,
|
|
165
|
+
"issued_at": envelope.issued_at,
|
|
166
|
+
"payload_hash": envelope.payload_hash
|
|
167
|
+
}
|
|
168
|
+
signed_bytes = cjjson(signed_bytes_obj)
|
|
169
|
+
|
|
170
|
+
signature = b64url_decode(envelope.signature)
|
|
171
|
+
if not MossCrypto.verify(public_key, signed_bytes, signature):
|
|
172
|
+
return VerifyResult(
|
|
173
|
+
valid=False,
|
|
174
|
+
subject=envelope.subject,
|
|
175
|
+
reason="Invalid signature",
|
|
176
|
+
error_code="MOSS_ERR_004"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return VerifyResult(
|
|
180
|
+
valid=True,
|
|
181
|
+
subject=envelope.subject,
|
|
182
|
+
payload_hash=envelope.payload_hash
|
|
183
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cryptographic identity and signed outputs for AI agents
|
|
5
|
+
Project-URL: Homepage, https://mosscomputing.com
|
|
6
|
+
Project-URL: Repository, https://github.com/mosscomputing/moss
|
|
7
|
+
Author: MOSS Computing
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agents,ai,cryptography,identity,post-quantum,signing
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: cryptography>=41.0.0
|
|
20
|
+
Requires-Dist: dilithium-py>=0.1.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# MOSS
|
|
27
|
+
|
|
28
|
+
**Know exactly which agent produced what.**
|
|
29
|
+
|
|
30
|
+
MOSS (Machine to Machine Operation Security System) is a cryptographic signing protocol for AI agents. Every agent gets an identity. Every output gets a signature. Verifiable, tamper-proof, reproducible.
|
|
31
|
+
|
|
32
|
+
[](https://github.com/mosscomputing/moss/actions/workflows/ci.yml)
|
|
33
|
+
[](https://badge.fury.io/py/moss-sdk)
|
|
34
|
+
[](https://opensource.org/licenses/MIT)
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install moss-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from moss import Subject
|
|
46
|
+
|
|
47
|
+
# Create an agent identity
|
|
48
|
+
agent = Subject.create("moss:dev:my-agent")
|
|
49
|
+
|
|
50
|
+
# Sign any output
|
|
51
|
+
envelope = agent.sign({"action": "approved", "amount": 500})
|
|
52
|
+
|
|
53
|
+
# Verify
|
|
54
|
+
result = Subject.verify(envelope)
|
|
55
|
+
assert result.valid
|
|
56
|
+
print(result.subject) # moss:dev:my-agent
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Why MOSS?
|
|
60
|
+
|
|
61
|
+
Multi-agent pipeline breaks, four agents touched it, no idea which one — you've been there. MOSS gives every agent an identity and signs every output so you can trace, diff, and verify.
|
|
62
|
+
|
|
63
|
+
**Security guarantees:**
|
|
64
|
+
- **Identity** — Unique subject per agent, bound to cryptographic key pair
|
|
65
|
+
- **Integrity** — Payload hash detects any modification
|
|
66
|
+
- **Replay protection** — Sequence numbers prevent reuse
|
|
67
|
+
- **Post-quantum** — ML-DSA-44 (FIPS 204) signatures survive quantum
|
|
68
|
+
|
|
69
|
+
## CLI
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Create a subject
|
|
73
|
+
moss subject create moss:dev:my-agent
|
|
74
|
+
|
|
75
|
+
# Sign a payload
|
|
76
|
+
echo '{"action": "test"}' | moss sign moss:dev:my-agent - > envelope.json
|
|
77
|
+
|
|
78
|
+
# Verify
|
|
79
|
+
moss verify payload.json envelope.json
|
|
80
|
+
|
|
81
|
+
# Compare envelopes
|
|
82
|
+
moss diff envelope1.json envelope2.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Framework Integrations
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install moss-sdk-crewai # CrewAI
|
|
89
|
+
pip install moss-sdk-autogen # AutoGen
|
|
90
|
+
pip install moss-sdk-langgraph # LangGraph
|
|
91
|
+
pip install moss-sdk-langchain # LangChain
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Specification
|
|
95
|
+
|
|
96
|
+
MOSS implements the `moss-0001` specification. See [SPEC.md](SPEC.md) for the full protocol specification.
|
|
97
|
+
|
|
98
|
+
### Envelope Format
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"spec": "moss-0001",
|
|
103
|
+
"version": 1,
|
|
104
|
+
"alg": "ML-DSA-44",
|
|
105
|
+
"subject": "moss:acme:order-bot",
|
|
106
|
+
"key_version": 1,
|
|
107
|
+
"seq": 42,
|
|
108
|
+
"issued_at": 1733200000,
|
|
109
|
+
"payload_hash": "<base64url(SHA-256(canonical(payload)))>",
|
|
110
|
+
"signature": "<base64url(ML-DSA-44 signature)>"
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Verification Procedure
|
|
115
|
+
|
|
116
|
+
1. Require `spec == "moss-0001"`
|
|
117
|
+
2. Compute `hash = base64url(SHA-256(canonical(payload)))`
|
|
118
|
+
3. Assert `hash == envelope.payload_hash`
|
|
119
|
+
4. Resolve `(subject, key_version) → public_key`
|
|
120
|
+
5. Verify `ML-DSA-44.verify(public_key, canonical(signed_bytes), signature)`
|
|
121
|
+
|
|
122
|
+
## Local-First
|
|
123
|
+
|
|
124
|
+
MOSS works entirely offline. Keys are stored locally at `~/.moss/keys/`. No account required. No cloud. No tracking.
|
|
125
|
+
|
|
126
|
+
To encrypt keys at rest, set:
|
|
127
|
+
```bash
|
|
128
|
+
export MOSS_KEY_PASSPHRASE="your-secure-passphrase"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## What MOSS Collects
|
|
132
|
+
|
|
133
|
+
**In the envelope:** `subject`, `payload_hash` (not the payload itself), `seq`, `issued_at`, `signature`
|
|
134
|
+
|
|
135
|
+
**Never:** Your payload content, LLM prompts/responses, user data, PII, telemetry
|
|
136
|
+
|
|
137
|
+
## Technical Details
|
|
138
|
+
|
|
139
|
+
| Component | Value |
|
|
140
|
+
|-----------|-------|
|
|
141
|
+
| Signature | ML-DSA-44 (FIPS 204, Dilithium2) |
|
|
142
|
+
| Hash | SHA-256 |
|
|
143
|
+
| Encoding | base64url (no padding) |
|
|
144
|
+
| Canonicalization | RFC 8785 (JCS) |
|
|
145
|
+
| Key encryption | AES-256-GCM + Scrypt |
|
|
146
|
+
| Public key size | 1312 bytes |
|
|
147
|
+
| Signature size | 2420 bytes |
|
|
148
|
+
| Overhead | <1ms sign, <1ms verify, ~2KB envelope |
|
|
149
|
+
|
|
150
|
+
## Contributing
|
|
151
|
+
|
|
152
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
153
|
+
|
|
154
|
+
## Security
|
|
155
|
+
|
|
156
|
+
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT — see [LICENSE](LICENSE)
|
|
161
|
+
|
|
162
|
+
## Links
|
|
163
|
+
|
|
164
|
+
- **Website:** [mosscomputing.com](https://mosscomputing.com)
|
|
165
|
+
- **Spec:** [SPEC.md](SPEC.md)
|
|
166
|
+
- **Discord:** [Join](https://discord.gg/2QewJsBc)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
moss/__init__.py,sha256=pGa_nS4JAM8w6KCU9qJXZn8m4d5rtJVYDfI7-0OGPeg,519
|
|
2
|
+
moss/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
moss/canonical.py,sha256=A8O_Chu1n49u4u_TfabmJqp3Fl4Parqi0s4N5EK2Fhw,684
|
|
4
|
+
moss/cli.py,sha256=08aHw_LzhrMQt6fQIw8VImRrVt1G-sJXTY26HajMHho,4012
|
|
5
|
+
moss/crypto.py,sha256=d4lhq0KfYwd4P7GQQsw-_LyToSK2Hdpq-sFNxAxRY-4,966
|
|
6
|
+
moss/encoding.py,sha256=zn5ndDydH_-S5cbxz5RFKTZeKb-bW7e_PBtcmnByNLk,378
|
|
7
|
+
moss/envelope.py,sha256=ucjDa2RViOv1OiRFk5_0Dvl776w2n6w0kckc9qPPpAY,1017
|
|
8
|
+
moss/errors.py,sha256=KNO6haC1Sw6R453RTzafZgZbrwrhM7ZjhLIo2HPu3Fw,830
|
|
9
|
+
moss/keystore.py,sha256=z6_0EtVuOY4u7UAbT-cAQSGWIqilclcji7vS1vD37sQ,3702
|
|
10
|
+
moss/sequence.py,sha256=sb5u1Ead_5A_KI99NdFR-xvKQKtEPgagi563jBqeH_s,1018
|
|
11
|
+
moss/subject.py,sha256=ojCXV--UlZHEa_ZJtHIJKpVPPArUb03FAqC7QOkOAS0,6120
|
|
12
|
+
moss_sdk-0.1.0.dist-info/METADATA,sha256=zokkRsXiTCS-PyA2h4sP4zgFOkftTRkb_Dy-nwMetpM,4813
|
|
13
|
+
moss_sdk-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
moss_sdk-0.1.0.dist-info/entry_points.txt,sha256=kc1MH_7F5_YF0TB7rUTdb33A9KachU9DZGmlNBBMA9Y,39
|
|
15
|
+
moss_sdk-0.1.0.dist-info/RECORD,,
|